分布式锁是相对于JVM本地锁来说的,分布式锁能跨进程、跨服务、跨服务器来解决并发线程安全问题,使用分布式锁最多的两个场景是超卖现象和缓存击穿
对于超卖现象库存数据处于NoSQL数据库的时候分布式锁一定是最佳解决方案
类似于Mysql这种数据库数据以文件的形式存放在硬盘上,可以支持很高的数据量,但是性能很低,会导致系统吞吐量下降,系统可能被高并发请求冲垮;可以通过在mysql的上一层使用NoSql数据库如Redis加一层缓存,像Redis这种内存型数据库,性能高,但是支撑的数据量会比较小,随便一个Redis单节点都能支撑10w级别的并发量,Redis能对系统起到很好的保护作用并提升系统的性能
缓存击穿现象的原因是热点key过期,因为内存型数据库的内存是有限的,如果不给数据设置过期时间,内存最终就会被耗尽,因为缓存被设置了过期时间,缓存中数据一旦过期就会导致大量瞬时并发请求访问mysql获取热点数据,在并发量太大的情况下就会冲垮mysql数据库,此时可以在mysql前加一层锁来限制只让一个请求访问mysql数据库重建缓存
像缓存击穿这种场景使用JVM本地锁很多时候是无法满足实际业务需求的,因为一方面热点数据可能有很多,还是会发生短时大量请求访问数据库重建缓存,另一方面服务很少是确定永远就是单体部署,一旦有服务集群JVM本地锁就可能失效,老师说的上百个服务器太夸张了,其实使用JVM本地锁最多也就放几个十几个请求过去痛击数据库,当然热点数据一多并发量也会成倍数十倍的增长,即使用本地锁对系统还是有不可控的风险
分布式锁的实现方式主要有三种,分别是基于redis实现、基于Zookeeper/etcd实现、基于mysql实现,特征都是基于框架的性质来实现独占排他使用
概念:
因为多个用户即多个线程并发访问共享库存数据,在不采取上锁等措施情况下因为多个线程的临界区代码执行次序错乱导致丢失部分数据更新操作,最终导致库存扣减小于实际的卖出数量,当库存耗尽前已经售出超过库存数量的商品导致无货可发,这就是超卖现象
概念:查询一个一定不存在的数据,默认情况下没有该数据的缓存,由于缓存不命中,请求将会去查询数据库,但是数据库也没有该记录,如果不将此次查询的结果null写入缓存,那么相同的请求每次都会去请求数据库,如果有恶意请求针对不存在商品进行高频攻击,会给数据库造成瞬时高压,可能直接把数据库压垮
解决办法:
查询查不到结果,就将空结果也进行缓存,并设置一个短暂的过期时间,这样一方面是避免缓存过大,另一个方面是避免空值数据万一有了数据无法及时更新
也可以使用布隆过滤器对高频ip进行封禁
概念:设置缓存时key使用了相同的过期时间,导致缓存再某一时刻同时失效,然而此时的并发请求非常高,瞬间请求压力全部给到数据库,数据库瞬间压力过重雪崩
解决办法:在原有失效时间上添加一个短时间内的随机值【如1-5min随机】,这样每个缓存的过期时间重复率降低,从而很难发生极短时间内缓存集体失效的情况
某些热点数据可能在瞬间突然被超高并发地访问,比如秒杀,但是对应的key正好在大量请求瞬间到来前已经失效,且在超高并发请求到来前没有请求再次形成缓存,那么瞬间的超高并发对同一个key对应的数据查询压力全部落在数据库上,称为缓存击穿,又比如一个接口只缓存一个数据结果,但是这个结果总会失效,失效的瞬间加入还是高并发请求【如首页商品分类数据】,此时所有并发查询压力就会直接加到数据库上
解决办法:
对重建缓存的过程加双重检查锁,对超高的瞬时并发,只让一个请求通过去重建缓存,剩下的请求都等待缓存
共享数据的存储媒介,针对不同的存储场景列举和分析解决并发操作共享数据线程安全问题的手段
共享资源如果放在mysql关系型数据库的情况下,要避免使用JVM本地锁,除非能避免锁和以下三种情况包括所在类使用多例模式、事务、服务集群部署任意一种共存,但是除了多例模式,事务和集群部署是很难避免的
我们还可以通过Mysql本身提供的锁机制来解决JVM本地锁失效的问题
数据场景
共享数据存在于Mysql数据库中,适用于系统并发量小的业务场景,多个线程在一个方法中并发对mysql中的共享数据进行先查后改操作,修改操作为对当前数据减1
测试
测试环境:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求直连单个服务,服务操作虚拟机上的数据库
测试结果:仅操作服务本地缓存数据,没有涉及数据库时吞吐量为5000次/s;添加数据库,单个线程在一个方法内获取一次库存数据,更新一次库存数据,吞吐量减小为2000,数据库库存数据从5000变成4789,5000次扣减库存请求全部成功,理论上数据库库存应减为0,发生超卖现象,出现线程安全问题
从性能上来说,一句SQL>>Mysql悲观锁>JVM本地锁>乐观锁
追求极致性能,业务场景简单且不需要记录数据前后变化的情况下优先选择一句sql
如果写并发量较低(多读少写),争抢不是很激烈的情况下优先选择乐观锁
❓:这有问题吧,乐观锁循环连接数据库,比JVM本地锁性能还差,慎重考虑
就mysql这个业务场景来说,如果写并发量较高竞争激烈,选择乐观锁会导致业务代码不间断的重试,优先选择mysql悲观锁
不推荐使用jvm本地锁
业务逻辑:对查数据库数据和更改数据库数据在服务器层面整体加可重入锁,用无锁并发的方式保证临界区代码原子性,实现对数据库数据的串行更新
测试
测试环境:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求直连单个服务,服务操作虚拟机上的数据库
测试结果:吞吐量为544,所有库存扣减请求全部成功,数据库库存被正确减为0
结果分析:吞吐量减少相较于修改本地服务内存缓存降低了90%,对数据库访问和加可重入锁对系统并发性能削减很可观,访问数据库导致吞吐量减半,然后加可重入锁导致性能继续降低至访问数据库条件下吞吐量的25%【注意5000的吞吐量是在控制台进行了5000次打印操作,如果没有打印操作将会更高】
解决方法优缺点
ReentrantLock和Synchronized都属于JVM本地锁,拥有相同的优缺点,ReentrantLock解决线程安全问题方法的分析见Synchronized解决方法优缺点
业务逻辑:对查数据库数据和更改数据库数据在服务器层面整体加Synchronized,用锁独占阻塞的方式来保证临界区代码原子性
测试
测试环境:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求直连单个服务,服务操作虚拟机上的数据库
测试结果:吞吐量为554,所有库存扣减请求全部成功,数据库库存被正确减为0
结果分析:synchronized和ReentrantLock在这种场景下性能差不多,ReentrantLock只是减少了上下文切换的性能开销
解决方案优缺点
三种情况可能导致JVM本地锁失效
多例模式可能导致JVM本地锁失效:ReentrantLock和Synchronized一般只能应用在单例对象的方法中来保护对共享成员变量的访问【比如在SpringBoot中的Service通过注入stockMapper来使用同一个单例Mapper操作数据库中的一个共享库存数据】,SpringBoot中默认service就是单例的,因此使用JVM本地锁使用能保证线程安全问题,但是一旦在service类名上通过配置@Scope(value="prototype",proxyMode=ScopedProxyMode.TARGET_CLASS)
将service改成多例的,ReentrantLock就可能变成多把锁,Synchronized如果是原本锁当前类对象,导致仍然会有多个线程同时操作共享数据出现线程安全问题,经过测试虽然吞吐量上来了变成了1811【几乎接近没上锁访问数据库】,但是发生了超卖现象,库存数量理论上为0实际上为4850,出现了线程安全问题
使用单体事务注解@Transactional
可能导致JVM本地锁失效:在添加JVM本地锁的方法上添加事务注解@Transactional
可能导致锁失效,锁失效是有概率的,不一定会失效,但是多线程并发的情况下概率也很高,锁失效的原理是事务注解@Transactional
是通过AOP的方式来在方法执行前开启事务,在方法执行结束后提交事务,加上JVM锁以后单个线程的执行逻辑是开启事务--获取锁--操作数据库--释放锁--提交/回滚;如果是多个线程并发就可能在执行流程期间出现线程安全问题,原因是Mysql的默认隔离级别是可重复读,即已经完成的数据库操作还没有提交,查询操作就返回提交前的数据库数据。如果两个线程同时执行同一个上了JVM锁的方法,两个线程都开始事务然后去竞争锁,线程1抢到锁,线程2进入阻塞;线程1执行完数据库操作还没有提交就把锁释放了,因为Mysql的默认事务隔离级别是可重复读,那么在线程1的对数据库操作还没有提交的情况下线程2已经读取了数据库,此时就会读到线程1读取到的还没有被线程1修改的库存数据,当线程2提交的时候就会直接覆盖线程1的修改结果,导致线程1的更新操作全部丢失,发生超卖现象,出现线程安全问题,根本原因是mysql的默认隔离级别是可重复读Repeatable_Read
以及JVM提供的锁锁不住AOP实现的由@Transactional
在方法开始前开启锁,在方法结束后再提交事务,在释放锁和提交事务期间其他线程就可能读取到还没有被提交的旧数据
🔎:一般数据库的事务隔离级别会设置为RC
【读已提交】和RR
【可重复读】,但是这两种隔离级别都是读取已经提交后的数据
集群部署下JVM本地锁失效:JVM本地锁在服务使用集群部署时操作数据库或外置缓存共享数据时也会导致锁失效,原因类似于Ioc组件在多例模式下锁组件本身或者可重入锁是非静态成员变量时锁失效的原因,根本原因还是允许多个线程同时对共享数据进行写操作;使用两台运行实例,使用nginx做负载均衡,吞吐量略微提升至610,发生线程安全问题,出现超卖现象;单台nginx只负载一台运行实例,吞吐量为493,相较于直连运行实例性能稍降降的不多
业务逻辑:
整个方法所有对数据库的增删改查操作都只使用一条SQL,就能避免加事务发生锁失效;
同时因为mysql内部使用悲观锁来保证一条SQL执行的原子性,因此即使是集群部署环境下,也能保证多个线程对数据库中同一个共享数据的线程安全问题;
因为mysql内部保证了一条SQL语句的原子性,因此方法连JVM的锁都不用加
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个数据库的同一个共享数据,查询数据库库存和更新数据库库存两条SQL简化为一条SQLupdate db_stock set count=count-#{count} where product_code=#{product_no} and count >= #{count}
2️⃣:在1️⃣的环境基础上再开启服务组件的多例模式
3️⃣:在1️⃣的环境基础上在服务中使用@Transactional
注解手动开启事务
测试结果:
1️⃣:测试1的吞吐量为1953,所有库存扣减请求全部成功,数据库库存被正确减为0
2️⃣:测试2的吞吐量为2224,所有库存扣减请求全部成功,数据库库存被正确减为0
3️⃣:测试3的吞吐量为1850,所有库存扣减请求全部成功,数据库库存被正确减为0
结果分析:一条SQL因为mysql内部加了悲观锁,因此服务中不需要加锁,不需要加事务,集群下也能保证数据库共享数据的操作原子性,性能提升非常可观;不仅能满足集群部署无锁并发线程安全性,还不需要加事务[1],而且在服务组件单例或者多例模式下都能保证共享数据的并发线程安全,不管是锁、事务还是多例都能可观影响系统的并发性能
解决方案优缺点
优点
能显著提升系统的并发性能,完美解决JVM本地锁三种难以避免的失效场景,还能避免使用锁,事务,多例模式下也是并发线程安全的
缺点
Mysql中的锁范围问题,mysql中会根据SQL语句调整锁的范围,如果是使用的表锁就会锁整张表,在并发业务下这种表锁是不可接受的,需要使用更细腻的行级锁来锁操作涉及的相关记录
很难应对复杂的业务场景,比如数据库中经常存在一个商品在多个仓库都有库存的情况,现实业务中往往存在一个仓库无货但是可以从其他仓库调货的情况,但是要使用数据库来做这种业务逻辑判断使用一条SQL实现是比较困难的,对复杂业务很不友好
也因为一条SQL的限制,很难记录共享数据变化前后的准确状态输出到日志中
使用一条SQL来通过mysql保证SQL执行的原子性,会有很难控制SQL语句使用行级锁,导致锁表大幅降低mysql表的查询效率的问题,同时一条sql很难写出复杂的业务逻辑,很难记录数据变化前后状态
如果我们能通过select...for update
查询共享数据的同时使用行级锁锁住表中对应的记录,那么就能保证锁的范围足够小,不影响同一张表其他记录的并行操作,同时利用数据库的锁来保证同一个线程对同一条记录一系列操作的原子性,还可以通过服务器来对数据进行复杂的业务逻辑判断,这一切都源于给select语句加了for update后缀来让查询操作也会导致对对应的记录上锁【这里的行级锁也要满足悲观锁使用行级锁的要求,但是因为这是单条查询语句,行级锁的满足条件比较容易控制】
业务逻辑:
给整个更新数据库的业务方法添加@Transactional
注解添加手动事务
使用SQL语句select * from db_stock where product_code=#{productCode} for update
查询库存表所有商品编号为productCode
的商品库存记录,并给所有查询出的记录上行级锁,
查询出商品所在所有仓库以后,实际开发中对选用哪一个仓库是要经过很复杂的判断的,比如距离远近、是否还有货等等业务逻辑进行判断, 这里简单地只判断库存数量是否足够,
选中仓库就扣减对应的库存数量
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个数据库的同一个共享数据,使用手动事务,用select ... for update给所有可能操作的记录上行级锁,用后端服务来完成复杂逻辑的运算
测试结果:
1️⃣:测试1的吞吐量为600,所有库存扣减请求全部成功,数据库库存被正确减为0【这里老师简化了仓库选择逻辑,直接扣除第一个仓库5000次库存的】
结果分析:
1️⃣:使用mysql的行级锁的并发性能比JVM本地锁稍微好一点,但是比单条SQL差很多,因为此前是锁一条SQL,一个锁周期内服务器只会和数据库做一次交互,此时为了复杂业务逻辑使用行级锁和手动事务,一个锁周期内服务器会多次和数据库交互
解决方案优缺点
优点:
相比于一条sql能处理复杂的业务逻辑,可以获取实时的数据进行分析,可以使用代码自己写业务逻辑,也可以使用第三方框架或者大数据框架来对数据进行分析,相比于一条sql获取处理数据更灵活、功能强大的多;行级锁也更好控制
缺点:
mysql的悲观锁性能即使是行级锁并发性能只是比JVM本地锁略高,比一条sql的性能要低的多
mysql的悲观锁同样存在死锁现象,死锁发生在两个客户端都开启了事务,且都想对对方已经上锁的记录进行上锁,注意,任何客户端都能同时开启事务并尝试对已经上锁的记录加锁,并发情况下容易引起死锁问题,注意Mysql发生死锁会报错DeadLock
一旦表中有一个地方使用了select...for update
,其他业务逻辑不能使用普通的select来做业务避免出现并发性的问题
❓:这个存疑,记录被锁住了,其他线程还能操作吗?还是说使用了select...for update
如果其他不使用for update
上锁的操作不会等待锁会直接失败?
🔑:明白了,是因为虽然线程1使用select...for update上
锁了,但是其他业务线程2如果使用普通的select,select操作不会被阻塞,只有更新操作会被阻塞【类似CopyOnWriteArrayList的读写并发】,但是如果其他业务将还没提交的数据读取到,在处理期间上锁的原业务已经提交了,此时做出的更改就是基于线程1上锁前的数据,而非线程1提交后的数据,此时多业务对共享数据的更新操作就会出现线程安全问题,因此使用select...for update
所有表下的业务都得使用select...for update
Mysql乐观锁需要基于时间戳或者version版本号加上CAS机制来实现,但是mysql底层没有对CAS的实现,需要我们通过SQL语句来实现
业务逻辑
在要使用乐观锁的表中添加字段时间戳或者版本号,时间戳只需要使用当前最新时间即可,如果应用程序的时间戳比表中的时间戳旧或者不同,说明在应用程序处理期间发生了更新操作,就需要拿着新值对应用程序进行重试,此时数据库返回的结果是更新操作影响的行数是0,因为时间戳或者版本号对不上了,此时再次完整执行业务方法直到更新记录条数为1的情况下说明操作是线程安全的,jdbc的update相关方法的返回值就是本次更新影响的记录条数【Mybatis和MP中的也是一样的】,老师的重试使用的递归,后面测试两个服务都发生了栈内存溢出
更新成功需要把时间戳改成最后更新的时间,sql应该满足update 表名 set 目标字段=具体值,时间戳=当前系统时间 where 条件字段=具体值 and 时间戳=应用程序提前获取的时间戳
使用版本号和使用时间戳的逻辑是相同的,只是版本号要控制像时间戳一样单增,使用时间戳有个问题是时间精度的问题,如果只精确到毫秒时间间隔可能太短,这个还需要进行验证,并没有可靠的案例支持,使用版本号不存在这方面顾虑,update 表名 set 目标字段=具体值,版本号=版本号+1 where 条件字段=具体值 and 版本号=应用程序提前获取的版本号
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个数据库的同一个共享数据,使用手动事务,用版本号字段作为乐观锁状态位,在mysql中一行sql保证查询版本号和更新版本号的原子性,在应用程序中实现cas操作失败无锁重试,在方法上添加了事务注解,重试递归调用了同一个方法
2️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个数据库的同一个共享数据,使用手动事务,用版本号字段作为乐观锁状态位,在mysql中一行sql保证查询版本号和更新版本号的原子性,在应用程序中实现cas操作失败无锁重试,在方法上移除事务注解,仍然使用递归调用方法,但是每次尝试sleep(20)
阻塞睡眠20ms
测试结果
1️⃣:吞吐量48,错误率飙升至68%,两个服务都出现栈内存溢出,不断报错,最后抛出异常SQLTransientConnectionException
数据库连接超时异常
2️⃣:吞吐量229,且吞吐量越来越小,所有库存扣减请求全部成功,数据库库存被正确减为0
结果分析
1️⃣:无锁并发重试递归调用方法存在栈内存溢出的风险,而且并发度越高,竞争越激烈,栈内存溢出风险也越高;此外方法内进行CAS无锁重试,如果竞争太激烈更新失败会导致一直在重试,但是加了事务注解手动控制事务,在事务未提交以前,数据库的DML操作会给对应的记录加行级锁或者给整个表加表锁,此时后面的重试发起的sql操作是被阻塞等待的,根本不能成功,阻塞超过默认30s就会报SQL连接超时异常,把手动事务删掉,自动事务只会加在单条DML操作上,执行失败悲观锁会立即释放掉,因此重试时不会发生阻塞等待的问题;至于栈内存溢出是递归调用方法将方法加载到栈内存导致的,这里老师的解决方法是每次重试都间隔20ms,这特么也有点离谱,都间隔20ms也是并发啊,感觉使用do...while比较合理,不去循环加载方法区代码,老师这里只是减少了对递归方法的调用次数来减少栈内存溢出的概率,感觉不是很可靠
2️⃣:吞吐量越来越小是因为竞争越来越激烈,在不加事务注解对重试进行阻塞,对重试间隔控制在20ms的递归方法调用的情况下所有请求都成功,数据库也不会发生线程安全问题,这种乐观锁重试大量无用的对数据库访问严重影响系统性能,效果甚至不如JVM本地锁
解决方案优缺点
优点
乐观锁不会导致死锁,但是悲观锁是有一定概率导致死锁的
缺点
高并发情况下,性能极低,因为存在大量的重试访问数据库
乐观锁如果使用无序改动数据比如上面的库存数据作为判断是否可以进行CAS操作的判据就会有ABA问题,ABA问题即,在正式进行cas操作前,cas操作虽然成功,但是在获取旧值和执行cas操作期间旧值如果被修改为值B和其他值,但是执行CAS操作前又改回了A值,CAS操作本身是无法判断出来的,其他业务可能使用中间值就执行其他业务了,这里使用时间戳或者版本号单增不会出现这个问题
读写分离情况下可能导致乐观锁不可靠,读写分离一般会搭建主从集群,采用主从复制的形式,让写操作去主集群,让读取操作去从集群中读取数据
🔎:主集群发生写操作会把写操作记录到自己的binlog日志中,从集群会不停地从binlog日志中拉取日志并记录到从集群的relaylog中继日志中,从集群读取中继日志replay把主集群动作SQL重演一次,对从集群执行相同的写操作,这个过程延迟比较大,记录binlog日志、将binlog读取出来发送给从集群、写入relay日志,从relay日志读取进行replay一共四次IO外加一次网络IO导致延迟比较大,因此主从分离系统同步的延迟就较大
🔎:主从分离在高并发情况下就非常容易导致主集群中写操作已经发生,但是从集群还没有同步完成,导致读取不到已经写入主集群的新数据,此时使用乐观锁就不合适,本来更新操作已经发生,但是因为从集群有延迟,cas操作读取从集群数据,但是因为延迟读取不到新值只能读到旧值,导致CAS操作成功,丢失上一次修改,乐观锁失去了作用
概念
思考mysql的悲观锁默认是行锁还是表锁
测试
测试环境
1️⃣:两个mysql本地客户端操作同一个mysql数据库,在客户端1开始事务,使用更新语句update db_stock set count=count-1 where product_code='1001' and count>=0;
更新数据库数据,然后客户端2使用更新语句update db_stock set count=count-1 where id=3;
测试结果
1️⃣:客户端1开启事务执行更新语句后,客户端2执行更新语句更新相同表与客户端1更新数据不相关的记录,客户端2进入阻塞等待,等待客户端1
结果分析
1️⃣:mysql客户端的事务默认是锁整张表,是表锁。一次更新操作期间别的SQL是无法对同一张表的任何数据进行操作的;mysql中悲观锁使用行级锁的条件是数据的查询或者更新条件必须是索引字段[1],同时查询或者更新条件必须是具体值,如果是模糊查询就会导致索引失效或者定位条件使用字段!=具体值,这两种情况悲观锁还是会使用表级锁,像=和in都会使用行级锁
备注
mysql手动事务需要使用begin;
开始事务才会使用对应的锁来实现在提交事务commit;
前锁住数据库中正在被操作的目标记录,注意这期间使用查询SQL是不会给记录上锁的,只有写操作和select...for update
才会给对应的记录或者整张表上锁,select...for update
上行级锁也遵循上面结果分析的规则;应用中加@Transactional
注解也是使用手动事务,相当于mysql中使用手动事务;不使用手动事务的情况下mysql会对每行sql操作使用自动事务
数据场景
共享数据存在于Redis数据库中,多个线程在一个方法中并发对Redis中的共享数据进行先查后改操作,修改操作为对当前数据减1
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,没有任何锁或者保护线程安全措施
2️⃣:相同条件再测试一次
测试结果:
1️⃣:吞吐量2047,5000次扣减操作全部成功,但是Redis中库存数据应从5000减至0,实际是3704,出现超卖现象
2️⃣:吞吐量3268,5000次扣减操作全部成功,但是Redis中库存数据应从5000减至0,实际是3702,出现超卖现象
结果分析
1️⃣:出现线程安全问题,第二次测试的吞吐量相较于第一次性能提升非常大,这是因为第一次运行系统进行了预热
在单机、单例模式下使用JVM本地锁是线程安全的,这种锁性能一般,使用方法和缺陷和共享数据在Mysql的情况下是一致的,参考Mysql解决方法中的Synchronized和ReentrantLock即可,只能单机用,限制很大
使用Redis的watch
、multi
、exec
这套指令配合Redis事务来实现redis中的乐观锁操作,这种方式比较麻烦,而且可靠性较差
Redis事务
watch key [key...]
可以监控一个或者多个列举变量的变量值,搭配multi
和exec
一起使用,Redis的事务提交是一旦在开启事务multi
还没提交执行期间,如果watch
指令监听的变量被其他客户端更改了,本次开启事务还未提交exec
的指令将全部作废无法提交,exec
指令执行会返回nil
,会取消事务的执行
RedisTemplate
对象和StringRedisTemplate
对象都有对应的watch(key)
、watch(keys)
、multi()
、exec()
方法,但是想像在客户端中使用指令一样使用这些方法是不行的,运行方法会直接报错RedisCommandExecutionException
Redis指令执行异常,抛异常的原因是服务器端使用这几个指令的方法不对,不能像直接在redis-cli中使用指令一样使用这几个方法,需要使用redisTemplate.execute(SessionCallback<T> session)
redisTemplate.execute(SessionCallback<T> session)
中的SessionCallback<T>
是一个函数式接口,在该接口的注释中标明允许替代用multi/discard/exec/watch/unwatch
命令来使用事务,在服务端必须通过该对象来使用事务相关的指令,弹幕说是因为要绑定会话连接和当前线程;使用redisTemplate.execute(SessionCallback<T> session)
方法需要匿名实现函数式接口SessionCallback<T>
,需要重写其中的抽象方法execute(RedisCallback<T> action)
并在该方法中调用RedisTemplate
对象和StringRedisTemplate
对象对应的watch(key)
、watch(keys)
、multi()
、exec()
方法,但是不推荐直接通过RedisTemplate
对象和StringRedisTemplate
对象对这些方法进行调用,因为抽象方法的传参RedisCallback<T>
是一个接口,RedisTemplate
是其实现类,而StringRedisTemplate
是RedisTemplate
的子类,因此实际上该抽象方法传参就是容器组件中的StringRedisTemplate
,直接通过该参数action来调用Redis事务相关的方法watch(key)
、watch(keys)
、multi()
、exec()
本地客户端用法
【redis-cli客户端1】
x127.0.0.1:6379> set stock 3704
OK
127.0.0.1:6379> watch stock
OK
127.0.0.1:6379> get stock
"3704"
127.0.0.1:6379> multi
OK
#在执行下一步前在客户端2执行更新stock的方法
127.0.0.1:6379> set stock 3703
QUEUED
127.0.0.1:6379> exec
(nil)
127.0.0.1:6379> get stock
"3000"
#演示没有其他客户端干扰情况下的事务正常执行
127.0.0.1:6379> watch stock
OK
127.0.0.1:6379> get stock
"3000"
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set stock 2999
QUEUED
127.0.0.1:6379> exec
OK
127.0.0.1:6379> get stock
"2999"
【redis-cli客户端2】
xxxxxxxxxx
127.0.0.1:6379> set stock 3000
在服务端使用Redis事务的代码正确示例
xxxxxxxxxx
public void deduct(){
redisTemplate.execute(new SessionCallback<Object>(){
public Object execute(RedisOperations operations) throws DataAcessException{
//watch
operations.watch("stock");
//查询库存数据
String stock = operations.opsForValue().get("stock").toString();
//判断库存数据是否充足并扣减库存
if(stock != null && stock.length() != 0){
Integer stock = Integer.valueOf(stock);
if(stock > 0){
//multi
operations.multi();
operations.opsForValue().set("stock",String.valueOf(--stock));
//exec执行事务
operations.exec();
//乐观锁更改失败重试,执行事务的返回结果集为空,则代表减库存失败,需要重试
if(exec == null || exec.size() == 0){
try{
//防递归调用爆栈的,但是这个写法感觉很呆,用do while更优雅,老师说具体睡眠时间看机器性能,如果服务器性能不行或者redis机器性能不行,并发量太高栈或者redis都可能出问题
Thread.sleep(40);
deduct();
}catch(InterruptedException e){
e.printStackTrace();
}
}
//执行成功需要返回exec对象给execute方法
return exec;
}
}
return null;
}
});
}
【错误示例】
不要直接在自定义方法中使用watch
、multi
和exec
方法,这种调用方式会直接在服务端抛RedisCommandExecutionException
Redis指令执行异常
xxxxxxxxxx
public void deduct(){
//watch
stringRedisTemplate.watch("stock");
//查询库存数据
String stock = stringRedisTemplate.opsForValue().get("stock").toString();
//判断库存数据是否充足并扣减库存
if(stock != null && stock.length() != 0){
Integer stock = Integer.valueOf(stock);
if(stock > 0){
//multi
stringRedisTemplate.multi();
stringRedisTemplate.opsForValue().set("stock",String.valueOf(--stock));
//exec
stringRedisTemplate.exec();
}
}
}
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,使用指令watch
、multi
、exec
来实现乐观锁控制多线程并发安全
测试结果:
1️⃣:吞吐量370,5000次扣减操作全部成功,Redis中库存数据应从5000减至0,实际是0,没有出现线程安全问题
结果分析
1️⃣:Redis乐观锁能够解决并发线程安全问题,但是性能相较无锁情况下损失非常高,从原来的3000直接降成370
解决方案优缺点
缺点:
Redis乐观锁的性能低,使用Jedis客户端能稍微提高性能,但是性能损耗依然很大,因此不推荐使用redis乐观锁
使用Redis乐观锁过程中可能因为机器性能问题出现连接不够用的情况导致乐观锁失效
基于Redis的分布式锁实现是依靠Redis中的setnx
命令和del
命令
🔎: setnx <key> <value>
只有在<key>
不存在时才能添加指定的键值对,如果键值对已存在则不能添加键值对并返回0,不存在相同的<key>
则成功添加键值对并返回1;像set <key> <value>
这种命令如果已经存在相同的<key>
会直接将<value>
用新值覆盖
🔎:分布式锁的原理是假如有100个并发请求要去更改一个key对应value的状态来获取锁,只有一个请求能成功更改状态并获取到锁来操作共享资源,当操作共享资源结束以后通过指令del
直接将对应key的键值对删掉来释放锁,这样其他请求就能再次通过指令setnx <key> <value>
来获取分布式锁,竞争锁失败的请求就让其有间隔时间地递归或者循环执行setnx <key> <value>
来重试获取分布式锁,直到返回值为1;或者像类似重建缓存这种只需要单个请求执行的操作,当缓存重建好以后直接让其他请求放弃重试即可
操作Redis获取分布式锁以及获取锁失败的等待重试逻辑还是通过后端服务器的业务代码远程操作Redis实现
1️⃣:在业务方法执行前获取分布式锁,在finally语句块中进行解锁,避免出现异常锁无法释放导致其他请求线程无法获取指定分布式锁
🔎:redisTemplate.opsForValue()
没有setnx
方法,取而代之的是setIfAbsent
方法,表示如果键值对不存在才存入键值对,与之作用相反的方法是setIfPresent(key,value)
,表示只有当redis中存在对应的key才去覆盖对应键值对的value值
🔎:注意setIfAbsent
方法在Redis中返回的是1或者0,该方法将返回值处理成返回1为true表示获取锁成功,返回0为false表示获取锁失败
🔎:redisTemplate.opsForValue()
没有del
方法,对应的是delete
方法,上了锁一定要删掉,否则必然出现死锁现象
只考虑基于Redis的原子性的setnx实现上锁和del实现释放锁,
不考虑获取锁的重试间隔对系统性能影响【重试竞争太激烈或者重试间隔时间太长都不利于提高系统性能】
不考虑服务器宕机导致锁无法释放进而死锁的问题【给键值对设置有效时间来避免服务器以外宕机无法释放锁导致死锁】
不考虑获取锁和设置过期时间的两步操作的原子性问题【获取锁到设置过期时间期间服务器宕机有效时间没设置上也会导致死锁,使用完整的set指令可以实现原子性和setnx的功能】
不考虑设置有效时间导致的锁提前释放后续代码裸奔【锁自动续期】,在锁提前失效导致其他线程应该阻塞结果提前错误获取锁,当前线程上的锁被其他线程通过key删除键值对释放的问题【用uuid做当前线程上锁解锁同一把锁的唯一标识】
不考虑当前线程判断自己上的锁和删除锁两步操作的原子性【Redis没有删除前对键值对判断的一条指令,要保证原子性需要使用lua】
不考虑不可重入导致的线程死锁问题以及uuid作为锁释放重入标识的局限性
获取分布式锁的服务端代码示例
递归重试代码示例
xxxxxxxxxx
public class StockService {
private StockMapper stockMapper;
private StringRedisTemplate redisTemplate;
public void deduct() {
// 加锁setnx
// redisTemplate.opsForValue()没有setnx方法,取而代之的是setIfAbsent方法,表示如果键值对不存在才存入键值对,与之作用相反的方法是setIfPresent(key,value),表示只有当redis中存在对应的key才去覆盖对应键值对的value值
// 注意setIfAbsent方法在Redis中返回的是1或者0,该方法将返回值处理成返回1为true表示获取锁成功,返回0为false表示获取锁失败
Boolean lock = this.redisTemplate.opsForValue().setIfAbsent("lock", "111");
// 获取锁失败重试:递归调用整个业务方法,注意这里是递归调用的整个业务方法,那么一定要限制只有获取锁成功才能去执行业务方法,否则方法直接执行结束;不然如果获取锁成不成功都会去反复执行业务方法,只要逻辑清晰这种问题一般不会发生,写在这里只是给自己提一个醒
if (!lock){
try {
Thread.sleep(50);
this.deduct();
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
try {
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
} finally {
// 解锁
// redisTemplate.opsForValue()没有del方法,对应的是delete方法
this.redisTemplate.delete("lock");
}
}
}
}
循环重试代码示例
xxxxxxxxxx
public class StockService {
private StockMapper stockMapper;
private StringRedisTemplate redisTemplate;
public void deduct() {
//循环重试获取锁
while (!redisTemplate.opsForValue().setIfAbsent("lock", "111")){
try {
Thread.sleep(40);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
} finally {
// 解锁
// redisTemplate.opsForValue()没有del方法,对应的是delete方法
redisTemplate.delete("lock");
}
}
}
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,使用上诉递归重试代码示例实现的基于Redis的乐观锁对库存数量5000进行单次扣减1,累计5000次扣减请求
2️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,使用上诉循环重试代码示例实现的基于Redis的乐观锁对库存数量5000进行单次扣减1,累计5000次扣减请求
测试结果:
1️⃣:吞吐量549,5000次扣减操作全部成功,Redis中库存数据应从5000减至0,实际是0,没有出现线程安全问题
2️⃣:吞吐量605,5000次扣减操作全部成功,Redis中库存数据应从5000减至0,实际是0,没有出现线程安全问题
结果分析:
1️⃣:吞吐量和JVM本地锁操作mysql中的共享数据差不多,效率还是低,感觉循环重试的网络开销才是影响性能的主要大头,老师自己都说递归调用这种行为不好存在爆栈风险,此前mysql的递归调用是没有改的,最好还是使用循环来重试获取锁
2️⃣:基于Redis乐观锁的实现循环调用的性能比递归调用的性能略高
给键值对设置过期时间防止服务器宕机锁无法释放导致的死锁
使用基于Redis实现的乐观分布式锁的关注要点
1️⃣:不管是循环重试还是递归重试获取锁,都要控制重试的时间间隔,否则连接资源开销太大可能会压垮服务器,但是重试间隔时间太久也会压低系统性能,这个取间隔时间的标准要好好关注一下;
🔎:老师对睡眠时间的理解是设置合适的睡眠时间能降低线程对锁的整体竞争压力,反而能提高使用乐观锁的系统性能,但是没有提及如何选取一个合适的间隔时间
❓:而且没有考虑最大重试时间或者次数的问题,一旦出现问题根本无法获取锁,所有请求就会一直进行递归或者循环重试,实际应用中都需要根据业务需求在原理的基础上重新设计
2️⃣:此外下面的实现都是所有线程获取不到锁都会重试直到获取锁,这种方式适合解决超卖应用场景让每个扣减请求都成功正确扣减;对于类似重建分布式缓存这种场景只需要重建一次缓存,其余抢不到锁的请求阻塞等待,重建失败其余等待请求再次重试,缓存重建成功其余请求不再重试需要在该原理的基础上进一步修改,这个实际上应该在业务方法中自定义获取不到锁的行为,循环重试和递归重试都是业务方法的行为,分布式锁设计不需要考虑这一点,直接在业务方法中通过双重检查锁实现一次构建缓存即可
3️⃣:这种基于Redis实现的分布式锁还要注意防死锁的发生,因为锁是从第三方获取并通过指令远程操纵第三方对锁进行释放的,如果服务器【服务器此时也可以叫做redis客户端程序】在获取锁执行业务代码期间宕机了,即使释放锁的操作写在finally语句块中也是无法被执行的【即使该服务器恢复了代码也无法继续向下执行释放锁的代码】,那么此时锁就变成死锁永远得不到释放了,要解决这个问题可以给锁对应的键值对设置一个合适的过期时间;
🔎:Redis中提供指令EXPIRE <key> <seconds>
来为指定key的键值对设置秒级别的过期时间,还提供了指令PEXPIRE <key> <milliseconds>
来为指定key的键值对设置毫秒级别的过期时间,使用指令ttl <key>
能查看锁对应键值对的过期时间【键值对过期以后ttl <key>
指令的返回值为-2】。锁一旦过期就会自动被释放掉
🔎:给锁相关键值对设置有效时间的代码不能放在获取锁以后分步来执行,因为可能会发生第一次执行获取锁时,客户端程序还没来得及给锁对应键值对设置有效时间就宕机了,那么在这种情况下仍然会发生死锁现象,需要保证获取锁和设置过期时间操作的原子性,此时我们可以考虑使用复杂的set指令来通过一条指令同时实现获取锁和设置过期时间的目标;redis和mysql一样能保证一条指令执行的原子性
🔎:Redis中set指令的完整格式为set <key> <value> [Ex seconds] [Px milliseconds] [NX|XX]
,Ex表示设置秒级别的过期时间,Px表示设置毫秒级别的过期时间,NX表示当键值对不存在时该指令能成功执行,XX表示当键值对存在时该指令才能成功执行覆盖对应key的value值;指令set lock 111 ex 20 nx
的意思是当redis中以lock作为key的键值对不存在时将键值对<"lock","111">
存入redis中并设置键值对的过期时间为20s;这条指令对应封装在setIfAbsent(String key,String value)
的重载方法setIfAbsent(String key,String value,long timeout,TimeUnit unit)
中,循环重试加锁并原子性给锁设置有效时间的完整代码示例如下所示:
xxxxxxxxxx
public class StockService {
private StockMapper stockMapper;
private StringRedisTemplate redisTemplate;
public void deduct() {
//循环重试获取锁
while (!redisTemplate.opsForValue().setIfAbsent("lock","111",20,TimeUnit.SECONDS)){
try {
Thread.sleep(40);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
} finally {
// 解锁
// redisTemplate.opsForValue()没有del方法,对应的是delete方法
redisTemplate.delete("lock");
}
}
}
🔎:设置了过期时间也可能带来一系列问题,第一个问题是假如线程1获取了锁且给锁上了有效时间,但是线程1可能因为其他原因一直被阻塞或者锁的过期时间太短【比如锁的有效时间是3s,但是执行业务方法就需要5s或者重试次数太多,可能执行过程中redis中的锁就被自动释放掉了】,等到锁过期了继续执行后续的业务代码,此时线程1就处于无锁裸奔的情况,有可能会发生线程安全问题;除此以外后续上锁的请求线程如线程2上的锁还有可能直接被前一个提前自动释放锁的线程1执行完业务方法调用delete方法直接手动将线程1的锁给释放掉了,导致后续的连续出错,而且后续请求执行时间大于锁的有效时间也会继续自动释放,最终所有请求的执行都处于无锁裸奔状态,此时有锁和无锁就没啥区别了,相当于锁失效;此时有两个问题,第一个是一个线程可能会去释放另外一个线程的锁,第二个问题是锁可能会在业务方法执行结束前提前失效,因此还需要实现防非当前线程释放当前线程的锁,以及业务方法没执行结束锁过期让锁自动续期
以UUID作为锁的唯一标识让当前上锁的线程只释放自己上的锁,此外还要用lua脚本保证检查uuid和释放锁两步操作的原子性
防止非当前线程释放当前线程的锁,可以给当前线程上的锁加一个唯一标识,要实现每个线程锁唯一还要被所有线程抢占同一把锁可以让key相同,让对应的value作为当前线程的唯一标识【可以使用UUID作为该标识】;把释放锁的逻辑改成释放锁前先去判断一下锁是否是当前线程获取锁时的value,value只有和自己锁时相同才能由当前线程去释放锁,如果不相同则无需再释放锁,示例代码如下
xxxxxxxxxx
public class StockService {
private StockMapper stockMapper;
private StringRedisTemplate redisTemplate;
public void deduct() {
String uuid = UUID.randomUUID().toString();
//循环重试获取锁
while (!redisTemplate.opsForValue().setIfAbsent("lock",uuid,20,TimeUnit.SECONDS)){
try {
Thread.sleep(40);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
} finally {
// 解锁
// 判断value是否是自己设置的value值,这个StringUtils老师使用的是common-lang3中的StingUtils工具类
if(StringUtils.equals(redisTemplate.opsForValue.get("lock"),uuid)){
// redisTemplate.opsForValue()没有del方法,对应的是delete方法
redisTemplate.delete("lock");
}
}
}
}
🔎:上述代码还是存在问题,因为没有保证删除该锁和判断锁的value是自己设置的两个过程是原子性的,在获取锁和删除锁期间还是有可能锁过期其他线程上锁然后被当前线程调用delete方法删除其他线程上的锁,导致下一个请求无锁裸奔,可能出现并发线程安全问题,Redis中没有指令既能判断键值对还能同时删除对应键值对的,只能借助Lua脚本来进行实现
使用Lua脚本来保证上述释放锁查询判断value值和当前线程获取锁生成的value值相同并删除对应锁两个操作的原子性
使用lua脚本可以保证Redis多条指令原子性的原理是Redis客户端程序通过lua脚本把多个Redis指令一次性发送给Redis服务器,那么这些指令就不会被其他客户端指令打断。Redis的单线程设计也会保证脚本以原子性的方式执行即当某个脚本正在运行的时候,不会有其他脚本或Redis命令被执行。关于Lua语言的语法和Redis中使用Lua脚本执行Redis命令的知识请查看后端--Lua,这里只列举对应的java程序实现
RedisTemplate
或者StringRedisTemplate
对象中的execute
方法有一个重载方法execute(RedisScript<T> script,List<String>) keys,Object... args)
可以传递脚本script
,KEYS列表
和ARVG列表
🔎:execute
方法的第一个参数RedisScript
类型是一个接口且非函数式接口,该接口只有一个实现DefaultRedisScript
,因此第一个参数需要通过构造方法传参不带换行的lua脚本即scriptnew DefaultRedisScript(script)
将lua脚本封装成DefaultRedisScript
,但是特别注意,如果使用该构造方法new DefaultRedisScript(script)
封装lua脚本运行会直接抛异常UnsupportedOperationException
,必须使用重载的双参构造方法指定返回值类型即new DefaultRedisScript(String script,Class<Object> resultType)
指定返回值类型如new DefaultRedisScript(script,Boolean.class)
,这个返回值就是lua脚本输出在redis-cli控制台的返回值,这个返回值类型随便瞎写也不会报错,但是不指定返回值类型下列程序运行就会直接抛异常,注意new DefaultRedisScript<>(script,Boolean.class),
这个尖括号要求的也是返回值的类型,第二个参数指定了该尖括号就不需要指定了,如果指定要保证两个类型要一致
基于Redis和lua脚本实现的分布式锁的服务器端Java代码
xxxxxxxxxx
public class StockService {
private StockMapper stockMapper;
private StringRedisTemplate redisTemplate;
public void deduct() {
String uuid = UUID.randomUUID().toString();
//循环重试获取锁
while (!redisTemplate.opsForValue().setIfAbsent("lock",uuid,20,TimeUnit.SECONDS)){
try {
Thread.sleep(40);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
} finally {
//解锁,只需要尝试一次,解锁成功即可,解锁失败就不管了,感觉这也不合理,应该还要拿到删除的结果或者key是否还存在来进行重试判断,防止网络故障等原因,总之没有重试机制不是很专业
String script="if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class),Arrays.asList("lock"),uuid);
}
}
}
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,使用上诉基于Redis和lua脚本实现的分布式锁的服务器端Java代码示例实现的基于Redis的乐观锁对库存数量5000进行单次扣减1累计,使用uuid作为键值对的value,使用lua脚本来检查锁是否属于当前线程并删除键值对释放锁
测试结果
1️⃣:吞吐量650,5000次扣减操作全部成功,Redis中库存数据应从5000减至0,实际是0,没有出现线程安全问题
参考JDK的可重入锁ReentrantLock的上锁解锁原理实现分布式锁的上锁解锁,用lua脚本一次提交执行和Redis单线程保证lua脚本操作的原子性,用lua脚本进行Redis操作来实现Redis多步操作组合逻辑来实现类似可重入锁的上锁逻辑和解锁逻辑
解决分布式锁不可重入导致死锁的问题
原理:两个方法可能都需要占有同一把锁,当一个方法1占有锁以后调用另一个方法2,此时就会发生锁重入,如果锁设计时不支持可重入,那么就会发生方法1等待方法2执行结束以后才能释放锁,但是方法2需要方法1释放锁以后才能获取锁执行,此时就会发生死锁现象,因此在必要的情况下还需要设计分布式锁的可重入来避免发生死锁,分布式锁的可重入思想可以参考ReentrantLock的锁重入设计
ReentrantLock原理:这个黑马JUC把源码讲的很清楚了,看笔记就行,这里只记录要点
🔎:ReentrantLock的默认无参构造方法是构造非公平实现的同步器,构造方法传参true是构造公平实现,传参false也是构造非公平实现,公平和非公平的区别体现在新线程是否要等队列中的节点对应线程都释放锁以后才有资格抢占锁【公平实现】还是在进入队列以前不管队列中有无节点都可以首先尝试获取锁【非公平实现】,同步器管理控制锁的行为,同步器继承抽象父类AQS,AQS是一切Java层面实现的锁【独占锁ReentrantLock、共享锁ReentrantReadWriteLock、Semaphore、CountdownLatch】的基础,AQS底层主要由int类型的state属性、int类型的waitStatus、从AQS的父类继承来的Thread类型的exclusiveOwnerThread属性和一个FIFO队列构成;AQS中线程上锁实质是多线程并发调用CompareAndSetState方法使用cas操作更改state属性的状态,在CompareAndSetState方法中调用UNSAFE类【Unsafe类可以通过类对象和属性相对于对象的内存偏移量直接对属性内存进行操作,里面提供了大量硬件级别的CAS原子性操作】的compareAndSwapInt方法,这里面的偏移量的计算在static静态代码块中,在类加载的时候就计算完毕了,Unsafe对象的获取方法上加了注解@CallerSensitive
注解,只能在JDK的源码中使用【严重怀疑这里只是说AQS中获取的unsafe对象只能被JDK使用,具体回顾一下JUC中对Unsafe类的使用】,在用户自己的项目里面是使用不了的
🔎:AQS类中有2000多行代码,大部分的同步器功能都已经被实现好了,用户可以基于AQS实现自定义独占锁和共享锁,要实现独占锁需要重写AQS中的tryAcquire(int)
方法【该方法在AQS中直接抛异常,即子类不实现该方法上锁时就会直接抛UnsupportedOperationException
异常】和tryRelease(int)
方法;要实现共享锁需要子类重写tryAcquireShared(int)
方法和tryReleaseShared(int)
方法;ReentrantLock方法的同步器Sync只实现了tryRelease
方法,tryAcquire
方法由其非公平子类进行实现【非公平子类实现NonfairSync
实际上只重写了tryAcquire
方法和lock
方法】
ReentrantLock锁重入的最重要的逻辑是,如果state等于0就直接cas操作加锁;如果state不等于0就判断当前线程是否占有锁的线程,如果是state加1【即重入次数加1】,否则进入阻塞队列阻塞;释放锁时让state减1,判断当前线程是否是锁的持有者,判断新state值是否为0,为0表示锁解干净了,设置owner为null,将state设置为新值并返回true,后续根据true唤醒享元下一个节点;如果不为0表示锁还没释放干净,将state设置为新值并返回false
ReentrantLock方法的上锁流程
1️⃣reentrantLock.lock()
:用户调用ReentrantLock
的上锁方法
2️⃣nonfairSync.lock()
:ReentrantLock
方法默认调用的是非公平实现的lock方法
3️⃣AQS.acquire()
:非公平实现的lock调用的是同步器的继承自抽象父类AQS的acquire方法,在AQS的acquire方法中调用了非公平实现的tryAcquire
方法尝试获取锁,在tryAcquire
方法执行失败的情况下执行acquireQueued(addWaiter(Node.EXCLUSIVE),arg)
方法来创建获取锁失败的线程对应的节点,当AQS队列没有节点的情况下再尝试两次获取锁,如果还是获取不到,节点入AQS队列,当前线程使用LockSupport的park方法自我进行阻塞
4️⃣nonfairSync.tryAcquire()
:非公平实现的上锁方法tryAcquire
方法又调用了从父类Sync中继承来的nonfairTryAcquire
方法
5️⃣sync.nonfairTryAcquire(int acquires)
:acquires的值实际上为1,在该方法中获取state的值,如果state等于0使用cas操作将其改为1,如果成功表示上锁成功返回true方法结束,如果state不为0就检查当前线程是否为此前占有锁的线程,如果不是直接返回false返回进入阻塞等待的逻辑,如果是当前占有锁的线程说明发生锁重入,将state状态加1,此时也只有当前线程能运行到此处,因此这个更新state状态无需使用cas操作,重入成功直接返回true方法结束
6️⃣AQS.addWaiter(Node mode)
:该方法就是准备入队列前的准备方法了,首先创建一个关联当前线程的Node节点,检查AQS队列中的尾结点是否为null,如果不为null就使用cas操作将当前节点更改为尾节点,把当前节点连到尾结点上并返回node节点;如果尾结点为null说明队列中没有节点,就调用enq(node)
方法,然后返回node节点
7️⃣AQS.enq(final Node node)
:如果尾节点为空,说明AQS队列中没有等待的线程,但是当前线程因为竞争获取不到锁仍然要入队列,进入该方法是一个死循环,此时获取尾节点检查尾节点是否为null【这一步实际上是检查AQS队列的享元是否创建,没有享元就创建享元】,如果为null就创建一个不关联任何线程的Node节点【实际上是代表当前占有锁的线程,因为后面会根据享元的waitStatus,当前占有锁的线程来判断是否要唤醒后一个节点】,如果享元不为null就会在第二次循环中将当前线程对应的节点加到享元后面,并让享元的next指向当前节点,让当前节点的prev指向享元【所以AQS板上钉钉使用了双向链表结构】 ,让节点加入队列以后返回当前节点
ReentrantLock的解锁流程
1️⃣reentrantLock.unlock()
:用户调用ReentrantLock
的解锁方法
2️⃣nonfairSync.release(1)
:ReentrantLock
方法默认调用的是非公平实现的release方法,但是非公平实现中只有lock方法和tryAcquire方法,release方法也是从AQS中继承来的方法,而且release方法中的参数列表1是写死的,该方法中首先调用tryRelease(1)
方法尝试解锁,解锁成功【只有包括重入的锁完全释放才会返回true】检查AQS队列的享元是否存在,存在的情况下waitStatus是否不为0,不为0就需要调用unparkSuccessor(h)方法唤醒享元的后继节点
,该方法解锁成功就返回true,解锁失败就返回false
3️⃣nonfairSync.tryRelease()
:tryRelease方法对应基于AQS实现的独占锁的tryAcquire方法,两个方法都抛出UnsupportedOperationException
异常,都需要被子类同步器重写,可重入锁的tryRelease方法是由Sync而非NonFairSync实现的,这点与tryAcquire方法不同,这是因为公平锁和非公平锁的解锁流程是相同的,但是上锁时公平锁需要检查AQS队列中是否有节点,非公平锁不需要检查而是首先进行竞争的差异导致的;tryRelease方法首先会获取state的值并在值的基础上减1,判断当前线程是否锁的占有线程,不是直接抛异常IllegalMonitorStateException
,如果锁的占有者是当前线程,判断state是否等于0,如果是说明锁释放干净了将exclusiveOwnerThread设置为null,然后将state设置为新值,注意只有锁释放干净了该方法才会将exclusiveOwnerThread设置为null并返回true,只有在返回true的情况下才会去尝试唤醒享元后面的节点
参考ReentrantLock实现非公平的分布式可重入锁的lua脚本上锁和释放锁逻辑
🔎:通过Redis中唯一的key和setnx指令来实现独占锁,通过value使用uuid作为当前线程的唯一标识,
🔎:如何在Redis中保存锁的重入状态,可以考虑在UUID后面加个计数,但是这种方式实现起来比较复杂,在Redis中有一种数据结构叫Hash,Hash是一个键值对集合,key就是正常的key,value是一个String类型的field和value的映射表,即Java中的Map,value可以有很多个键值对,哈希特别适合存储对象,类似于Java中的Map<String,Object>,很像一个对象,key就是对象比如user对象的名字,有个字段叫age
,值为20
;还有个字段是银行余额balance
,值为5000
;即Hash可以认为是一个双层Map,也可以认为是一个对象
使用命令hset user name '柳岩'
插入Hash类型的数据到Redis中,对应的要实现基于Redis分布式锁可以将锁的数据封装成hset lock uuid 锁的重入次数
,即将基于Redis的分布式设计为使用hash数据类型+lua脚本+乐观锁机制
加锁实现逻辑如下,加锁、重入成功都返回1,加锁、重入失败都返回0,对应Java中的true和false
1️⃣:通过Redis指令EXISTS <key>
判断键值对是否存在线程占有锁,如果存在则返回1,不存在则返回0;
2️⃣:如果返回0说明没有线程占用锁,当前线程可以直接通过Redis指令hset <key> <field> 1
获取锁;通过Redis指令expire <key> <time>
为锁设置有效时间防死锁
🔎:ttl为-1表示当前键值对没有设置有效时间
3️⃣:如果返回1说明有线程占用锁,当前线程使用Redis指令HEXISTS <key> <field>
判断Redis中的锁的field是否当前线程的uuid,如果是返回1,如果不是返回0;
如果是当前线程的uuid,说明发生锁重入,此时使用Redis指令hincrby <key> <field> <increment>
将value值递增increment,然后使用Redis指令expire <key> <time>
将锁的有效时间进行重置
如果不是当前线程的uuid,说明锁已经被其他线程占有,此时线程应该进入阻塞,但是这个实现工作量太大,使用循环尝试或者递归重试加间隔时间代替,不亚于实现一个ReentrantLock,可以考虑直接在Java代码中使用ReentrantLock来阻塞唤醒,减少重试竞争
对应的lua加锁脚本
🔎:注意如果redis中没有对应的键值对,经过测试此时直接使用hexists lock uuid 1
和命令hset lock uuid 1
的效果是相同的,都是将该键值对加入hset中且value值为1,故下面的lua代码可以合并简化
xxxxxxxxxx
if redis.call('exists','lock') == 0 then
redis.call('hset','lock',uuid,1)
redis.call('expire','lock',30)
return 1
elseif redis.call('hexists','lock',uuid) == 1 then
redis.call('hincrby','lock',uuid,1)
redis.call('expire','lock',30)
return 1
else
return 0
end
【合并简化的lua代码】
🔎:lua的逻辑运算符只有and
、or
、not
🔎:所有参数都使用传参的KEYS列表和ARVG列表传参,增强lua代码的复用性;键值对的key用KEYS列表,uuid和有效时间用ARGV列表传递
xxxxxxxxxx
if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then
redis.call('hincrby',KEYS[1],ARGV[1],1)
redis.call('expire',KEYS[1],ARGV[2])
return 1
else
return 0
end
解锁逻辑如下,如果锁不存在lua代码中返回nil,对应java代码中的null,可以在java代码中手动抛出异常
1️⃣:使用Redis指令hexists
判断当前线程获取的锁是否存在,该指令不存在返回0,存在返回1
2️⃣:如果锁不存在就直接返回nil,由Java层面直接抛异常
3️⃣:如果锁存在直接使用Redis指令hincrby <key> uuid -1
将value值减1,然后判断减1后的value值是否为0,如果为0表示锁释放干净了直接使用Redis指令del <key>
删除对应键值对返回1;不为0表示锁还没有释放干净,直接返回0
🔎:注意指令hincrby <key> uuid -1
的返回值就是value减去1后的值
对应的lua释放锁脚本
xxxxxxxxxx
if redis.call('hexists',KEYS[1],ARVG[1]) == 0 then
return nil
elseif redis.call('hincrby',KEYS[1],ARVG[1],-1) == 0 then
return redis.call('del',KEYS[1])
else
return 0
end
使用Java在服务端远程操作Redis通过Redis、Lua脚本、乐观锁机制实现的可重入分布式锁
实现逻辑:
这个实现没有做基于AQS的阻塞唤醒,应该是可以在Java层面做到线程的阻塞唤醒的
创建分布式锁类DistributedRedisLock
,该类实现Lock接口,分布式锁只需要关注上锁和解锁方法,
上锁方法只需要实现代参数的tryLock方法,不带参数的tryLock方法可以传参过期时间-1表示默认过期时间30s的锁,lock方法直接调用tryLock方法
🔎:要使用自动注入注解来使用IOC容器组件的类也必须纳入IOC容器的管理才行,简单点添加@Component
注解,以后使用就可以注入DistributedRedisLock
直接使用,但是这样还是不够好,因为对于不同的分布式锁实现需要注入不同的组件,我们可以通过工厂设计模式来创建对应的实例对象,这样可以在工厂对象中使用IoC容器组件,分布式锁的具体实现不注入容器中,这样还能节省内存
重新设计Redis中锁的键值对Hset的field字段,让uuid作为服务的标识,让获取锁时以线程id作为锁的标识,用每个服务唯一的uuid:线程id作为field字段;field字段用于解决分布式环境下的锁的可重入实现同一个线程对同一把锁多次上锁,让获取分布式锁的标准只判断指定key的HSet是否存在,如果不存在则直接上锁,如果存在则根据当前线程的id和服务的uuid是否和当前线程一致,一致说明是锁重入进入锁重入逻辑;如果不一致说明指定key的锁发生竞争,当前线程进入等待重试
🔎:这里uuid:线程id
只是用来判断是否发生锁重入,不是用来判断锁是否被占有,锁被占有的判据是key,所以不会存在因为线程的id不同导致filed字段不同导致相同的key不同的field发生锁不住的情况,即key相同,但是field不同的线程无法锁重入,对应key的HSet存在,因此上不了锁,等待重试
🔎:此外,因为是以key对应的Hset作为锁,因此只要key不同,锁就不同,同一个线程要上多把锁可以根据Hset的key为区别区分不同的锁,即使这些锁的field是一样的但是他们的key不一样也是不同的锁,因此一个线程是可以上多把锁的
【基于Redis的可重入失败的分布式锁实现】
🔎:这里的上锁实现是执行lock方法首先去获取锁,但是一获取锁就去创建新的锁对象并创建新的uuid,即只要在不同方法中调用了distributedLockClient.getRedisLock("lock")
后续即使调用lock方法内部方法应该是锁重入也会因为上的是不同的锁导致锁重入失败,这是因为无法在定义方法时就确定方法调用顺序的前提下就要区分方法调用导致的锁重入使用同一把锁引起的,因此还需要在锁内部设计在获取锁时判断外层方法是否有锁;判断方法被同一个线程调用的最好的标识就是Thread的id,通过检查有没有当前线程对应的key和相同的field就知道调用者是否已经对同一把锁上锁
【分布式锁实现】
xxxxxxxxxx
public class DistributedRedisLock implements Lock {
private StringRedisTemplate redisTemplate;
private String lockName;
private String uuid;
//默认锁的有效时间
private long expire = 30;
//每次获取该分布式锁对象都重新创建锁对象和创建新的uuid,这是有问题的,因为在方法定义时就要获取,如果每个方法调用获取锁都重新创建锁对象对锁对象生成完全不同的uuid,此时每次获取锁上锁都是对不同的锁上锁,根本就不会发生锁重入
public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName) {
this.redisTemplate = redisTemplate;
this.lockName = lockName;
this.uuid = UUID.randomUUID().toString();
}
public void lock() {
this.tryLock();
}
public void lockInterruptibly() throws InterruptedException {
}
public boolean tryLock() {
try {
return this.tryLock(-1L, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
/**
* 加锁方法
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
if (time != -1){
//把过期时间都转换成秒来作为传给指令的秒参数,没指定时间就使用默认的30s
this.expire = unit.toSeconds(time);
}
String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
"then " +
" redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
" redis.call('expire', KEYS[1], ARGV[2]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
while (!this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuid, String.valueOf(expire))){
Thread.sleep(50);
}
return true;
}
/**
* 解锁方法
*/
public void unlock() {
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 " +
"then " +
" return nil " +
"elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 " +
"then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long flag = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), uuid);
if (flag == null){
throw new IllegalMonitorStateException("this lock doesn't belong to you!");
}
}
public Condition newCondition() {
return null;
}
}
【分布式锁工厂类实现】
xxxxxxxxxx
public class DistributedLockClient {
private StringRedisTemplate redisTemplate;
//通过get方法实例对应的锁对象,实现锁对象的懒惰实例化,对应方法执行完锁对象也会主动销毁
public DistributedRedisLock getRedisLock(String lockName){
return new DistributedRedisLock(redisTemplate, lockName);
}
}
【业务类对锁的使用】
xxxxxxxxxx
public void deduct() {
DistributedRedisLock redisLock = this.distributedLockClient.getRedisLock("lock");
redisLock.lock();
try {
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
} finally {
redisLock.unlock();
}
}
【基于Redis的可重入的分布式锁实现】
🔎:在上面实现的基础上更改表示锁的Redis中的HSet的filed字段,将field字段从uuid改成了uuid:线程id;让每次获取锁都获取全新的锁变成了每个服务对应一个uuid,同一个线程获取同一把锁,不同的锁通过key进行唯一标识,锁重入通过key和field字段共同标识,从而同时实现一个线程可以通过key不同上多把锁,也可以通过key和field都相同来进行锁重入,key同field不同来对要获取同一把key的锁的其他线程进行阻塞重试,用lua一次提交不可被打断和Redis单线程特性来保证对上锁和解锁操作的原子性
🔎:因为工厂类DistributedLockClient
加了@Component
注解,所以是单例的,其中的uuid属性只由该工厂类构造方法创建,因此也是单例的,所以uuid属性只是作为每个服务的判断标识,不再作为判断是否同一个线程锁重入的标识,使用更好的Thread.id
作为锁重入的标识,让uuid:线程id
来区分具体服务的线程,没有uuid两个服务的线程id可能相等,此时再按lua的上锁解锁逻辑就会导致另一个服务的同名线程上锁可以通过锁重入逻辑进行,发生锁不住的现象,所以此处作为服务标识的uuid也是必要的
【分布式锁实现】
xxxxxxxxxx
public class DistributedRedisLock implements Lock {
private StringRedisTemplate redisTemplate;
private String lockName;
private String uuid;
private long expire = 30;
public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName, String uuid) {
this.redisTemplate = redisTemplate;
this.lockName = lockName;
this.uuid = uuid;
}
public void lock() {
this.tryLock();
}
public void lockInterruptibly() throws InterruptedException {
}
public boolean tryLock() {
try {
return this.tryLock(-1L, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
/**
* 加锁方法
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
if (time != -1){
this.expire = unit.toSeconds(time);
}
String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
"then " +
" redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
" redis.call('expire', KEYS[1], ARGV[2]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
while (!this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), getId(), String.valueOf(expire))){
Thread.sleep(50);
}
return true;
}
/**
* 解锁方法
*/
public void unlock() {
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 " +
"then " +
" return nil " +
"elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 " +
"then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long flag = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), getId());
if (flag == null){
throw new IllegalMonitorStateException("this lock doesn't belong to you!");
}
}
public Condition newCondition() {
return null;
}
/**
* 给线程拼接唯一标识
* @return
*/
String getId(){
return uuid + ":" + Thread.currentThread().getId();
}
}
【分布式锁工厂类实现】
xxxxxxxxxx
public class DistributedLockClient {
private StringRedisTemplate redisTemplate;
private String uuid;
public DistributedLockClient() {
this.uuid = UUID.randomUUID().toString();
}
public DistributedRedisLock getRedisLock(String lockName){
return new DistributedRedisLock(redisTemplate, lockName, uuid);
}
}
【业务类对锁的使用】
xxxxxxxxxx
public void deduct() {
DistributedRedisLock redisLock = this.distributedLockClient.getRedisLock("lock");
redisLock.lock();
try {
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
} finally {
redisLock.unlock();
}
}
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,使用上诉基于Redis的hset和lua脚本实现的可重入失败分布式锁对库存数量5000进行单次扣减1,累计5000次扣减请求
2️⃣:在步骤1的基础上写一个获取相同分布式锁的方法1,在业务方法上锁的代码中调用方法1,让锁进行重入测试分布式锁的锁重入效果
3️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,使用上诉基于Redis的hset和lua脚本实现的可重入分布式锁对库存数量5000进行单次扣减1,累计5000次扣减请求
测试结果:
1️⃣:吞吐量591,5000次扣减操作全部成功,Redis中库存数据应从5000减至0,实际是0,没有出现线程安全问题
2️⃣:锁重入失败了,因为每次上锁前获取锁都是获取的不同锁,每次都要创建全新的uuid,就不可能对同一个锁第二次上锁,因此锁重入失败
3️⃣:吞吐量626,5000次扣减操作全部成功,Redis中库存数据应从5000减至0,实际是0,没有出现线程安全问题
自动续期可以使用定时任务来实现当锁快过期的时候给锁续期,使用lua脚本保证续期多步操作如判断锁是否是当前线程占用,是的情况下再续期,
🔎:定时任务实现有多种方式,常见的实现方式有JUC的任务调度线程池ScheduledExecutorService、Spring提供的@Scheduled
注解以及SchedulingConfigurer
接口实现以及第三方任务调度框架Quartz Scheduler、Elastic Job、分布式定时任务xxl-job、PowerJob等,但是这些定时任务不适用于这种场景,主要原因是分布式锁的使用比较随意,如果每有一个分布式锁就使用一个定时任务,使用框架对定时任务的管理不是很灵活;JUC的任务调度线程池可以通过控制线程池的线程数控制定时任务的个数,但是因为其基于线程池,不方便控制每个线程的定时任务
也可以创建一个新线程定义一个任务让其阻塞一段时间就去执行一次续期任务
自动续期在业务方法没执行完锁过期时用程序再次进行续期,如果服务器宕机,续期也会自动终止,续期不会导致锁出现死锁现象
演示JUC的任务调度线程池的局限性
JUC定时任务的销毁没有单独一个定时任务的销毁方法,唯一的关闭定时任务的API是shutdown()
或者shutdownNow()
会直接把整个定时任务线程池销毁,没有停掉一个定时任务的方法,想要某个任务停掉就很麻烦,所以JUC中的定时调度任务在这里也很不好用
🔎:老师说这里适合使用java.util.Timer
这个工具类来实现定时调度任务,Timer中创建定时任务和取消定时任务的方法都有,使用起来更方便;但是JUC的老师说Timer的优点是简单易用,但是致命缺点是所有的任务都是被同一个线程执行的,同一时间只能有一个任务执行,剩下任务都得等,执行过程中如果有一个任务发生延迟或者异常都会影响后续其他任务的执行,异常甚至会导致后续任务作废;
【JUC任务调度线程池使用示例】
xxxxxxxxxx
public static void schedule(){
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(3);
System.out.println("定时任务初始时间:"+System.currentTimeMillis());
scheduledExecutorService.scheduleAtFixedRate(()->{
System.out.println("定时任务的执行时间:"+System.currentTimeMillis());
},5,10,TimeUnit.SECONDS);
}
java.util.Timer
的使用
void--->timer.schedule(TimerTask task, long delay, long period)
功能解析:定义定时任务,task是任务对象,TimeTask是抽象类,需要实现抽象run方法;delay是初始延迟时间,单位是毫秒,period是两次执行的间隔时间,单位是毫秒
使用示例:
xxxxxxxxxx
/**
* Thread[main,5,main]| 定时任务初始时间:172418119 1863
* Thread[Timer-0,5,main]| 定时任务执行时间:17241811 96874
* Thread[Timer-0,5,main]| 定时任务执行时间:17241812 06887
* Thread[Timer-0,5,main]| 定时任务执行时间:17241812 16889
* Thread[Timer-0,5,main]| 定时任务执行时间:17241812 26904
* */
public static void main(String[] args) {
System.out.println(Thread.currentThread()+"| 定时任务初始时间:"+System.currentTimeMillis());
new Timer().schedule(new TimerTask() {
public void run() {
System.out.println(Thread.currentThread()+"| 定时任务执行时间:"+System.currentTimeMillis());
}
},5000,10000);
}
示例含义:定时线程启动后初始延时5秒钟开始每隔十秒打印定时任务执行时间
补充说明:
🔎:TimerTask
实现了Runnable
接口
基于Redis
+java.util.Timer
+lua脚本
来实现自动续期的可重入分布式锁
分布式锁的自动续期lua脚本逻辑
脚本逻辑:如果当前线程上的锁存在,就调用expire
指令对锁进行续期,续期成功返回1,如果当前线程的锁不存在,就直接返回0
xxxxxxxxxx
if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then
redis.call('expire', KEYS[1], ARGV[2]);
return 1;
else
return 0;
end
结合Java代码和lua脚本实现锁的自动续期
这个自动续期的定时调度任务的实现是只让定时调度任务执行一次,续期成功以后原定时调度任务线程直接结束销毁,由原定时调度任务再起一个新的定时调度任务执行下一个续期任务,这样的好处是续期任务定时线程会自己结束,当锁被释放以后定时调度任务就会自动停止设置下一个续期任务,能够灵活地避免考虑自动定时任务自动停止和意外情况下的停止问题,避免意外情况下发生无限续期导致死锁问题的发生
xxxxxxxxxx
public class DistributedRedisLock implements Lock {
private StringRedisTemplate redisTemplate;
private String lockName;
private String hashField;
private long expire = 30;
public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName, String uuid) {
this.redisTemplate = redisTemplate;
this.lockName = lockName;
this.hashField = uuid + ":" + Thread.currentThread().getId();
}
public void lock() {
this.tryLock();
}
public void lockInterruptibly() throws InterruptedException {
}
//无参tryLock方法是使用默认有效时间30s作为锁的有效时间
public boolean tryLock() {
try {
return this.tryLock(-1L, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
/**
* 加锁方法
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
if (time != -1){
this.expire = unit.toSeconds(time);
}
String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
"then " +
" redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
" redis.call('expire', KEYS[1], ARGV[2]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
while (!this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), hashField, String.valueOf(expire))){
Thread.sleep(50);
}
// 加锁成功,返回之前,开启定时器自动续期
this.renewExpire();
return true;
}
/**
* 解锁方法
*/
public void unlock() {
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 " +
"then " +
" return nil " +
"elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 " +
"then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long flag = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), hashField);
if (flag == null){
throw new IllegalMonitorStateException("this lock doesn't belong to you!");
}
}
public Condition newCondition() {
return null;
}
//上锁以后延迟1/3锁有效时间去定时执行续期脚本;注意execute方法会自动将lua的返回值1转成true,将返回值0返回false,注意nil也会转成false;如果需要区分nil需要使用Long类型的返回值,对应nil转成null;注意这个返回值不能写成Integer类型,会抛出大量异常,转成数字一律使用使用Long避免抛异常
//定时任务采用延时执行一次任务,执行完以后线程就销毁,如果续期成功再次执行该任务,续期失败说明锁已经不是当前线程的锁了,不再进行续期操作;如果不这么写就需要去解锁成功以后取消定时任务,需要考虑各种意外情况导致定时线程无法被取消导致的死锁的情况,实现起来比较复杂,但是这种实现在分布式锁比较多的情况下会占用很多的线程资源
//这里更改此前的getId方法是因为定时续期的execute方法也需要获取field字段,但是不能使用getId中的逻辑,因为定时任务需要开新的定时任务线程,但是field字段需要业务线程的线程id,因此这里只是改进将getId的逻辑放到了获取锁时的实例化锁对象的构造方法中,实际上锁重入只是key和Field字段相同,实际上锁重入并不是同一个DistributedRedisLock对象
private void renewExpire(){
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
"then " +
" return redis.call('expire', KEYS[1], ARGV[2]) " +
"else " +
" return 0 " +
"end";
new Timer().schedule(new TimerTask() {
public void run() {
if (redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), hashField, String.valueOf(expire))) {
renewExpire();
}
}
}, this.expire * 1000 / 3);
}
}
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,使用上述基于Redis
+java.util.Timer
+lua脚本
来实现自动续期的可重入分布式锁对库存数量5000进行单次扣减1,累计5000次扣减请求
测试结果:
1️⃣:没预热情况下吞吐量553,5000次扣减操作全部成功,Redis中库存数据应从5000减至0,实际是0,没有出现线程安全问题
比较完善的基于Redis的分布式锁实现如下
分布式锁DistributedRedisLock
xxxxxxxxxx
public class DistributedRedisLock implements Lock {
private StringRedisTemplate redisTemplate;
private String lockName;
private String hashField;
private long expire = 30;
public DistributedRedisLock(StringRedisTemplate redisTemplate, String lockName, String uuid) {
this.redisTemplate = redisTemplate;
this.lockName = lockName;
this.hashField = uuid + ":" + Thread.currentThread().getId();
}
public void lock() {
this.tryLock();
}
public void lockInterruptibly() throws InterruptedException {
}
//无参tryLock方法是使用默认有效时间30s作为锁的有效时间
public boolean tryLock() {
try {
return this.tryLock(-1L, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
/**
* 加锁方法
* @param time
* @param unit
* @return
* @throws InterruptedException
*/
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
if (time != -1){
this.expire = unit.toSeconds(time);
}
String script = "if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
"then " +
" redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
" redis.call('expire', KEYS[1], ARGV[2]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
while (!this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), hashField, String.valueOf(expire))){
Thread.sleep(50);
}
// 加锁成功,返回之前,开启定时器自动续期
this.renewExpire();
return true;
}
/**
* 解锁方法
*/
public void unlock() {
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 " +
"then " +
" return nil " +
"elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 " +
"then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long flag = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), hashField);
if (flag == null){
throw new IllegalMonitorStateException("this lock doesn't belong to you!");
}
}
public Condition newCondition() {
return null;
}
//锁自动续期
private void renewExpire(){
String script = "if redis.call('hexists', KEYS[1], ARGV[1]) == 1 " +
"then " +
" return redis.call('expire', KEYS[1], ARGV[2]) " +
"else " +
" return 0 " +
"end";
new Timer().schedule(new TimerTask() {
public void run() {
if (redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), hashField, String.valueOf(expire))) {
renewExpire();
}
}
}, this.expire * 1000 / 3);
}
}
工厂方法获取分布式锁对象
xxxxxxxxxx
public class DistributedLockClient {
private StringRedisTemplate redisTemplate;
private String uuid;
public DistributedLockClient() {
this.uuid = UUID.randomUUID().toString();
}
public DistributedRedisLock getRedisLock(String lockName){
return new DistributedRedisLock(redisTemplate, lockName, uuid);
}
}
业务方法使用分布式锁示例
业务逻辑是并发请求对100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,操作虚拟机上的同一个Redis数据库的同一个共享数据,对库存数量5000进行单次扣减1,累计5000次扣减请求,使用基于Redis的分布式锁解决Redis中共享库存数据的并发线程安全问题
xxxxxxxxxx
public void deduct() {
DistributedRedisLock redisLock = this.distributedLockClient.getRedisLock("lock");
redisLock.lock();
try {
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
} finally {
redisLock.unlock();
}
}
该分布式锁实现了以下特性
通过setnx
指令做到分布式锁的独占排他,后面用lua脚本一次提交执行和Redis单线程特性保证原子性并结合lua脚本的逻辑判断替换了setnx
指令的独占排他
通过设置过期时间来防服务宕机或者意外锁无法释放导致的死锁现象,使用完整的set指令来保证独占排他并同时设置有效时间保证上锁和设置有效时间两步操作的原子性,最后被lua脚本整合到同时实现上锁和锁重入的hincrby
指令和Expire
指令中,用Lua脚本保证上锁或锁重入与设置有效时间两步操作的原子性
通过Redis中的Hash数据类型,以key作为锁的唯一标识,以key加field字段【当前线程创建的UUID】作为线程自身上锁的唯一标识来防止当前线程误删其他线程上的锁,避免因为锁提前失效或者一系列其他原因导致的非上锁线程执行锁释放操作;后为了在定义方法时就确定同一个线程锁重入的自动识别,uuid很难实现在方法定义时就保证方法锁重入时两把锁的uuid相同,因此将当前线程标识即field字段重新设计为线程id
,为了避免集群环境下不同服务实例的线程id相同导致上锁通过锁重入获取锁导致锁失效问题,使用uuid作为服务的唯一标识,field字段使用uuid:线程id
结合key作为区分获取锁的当前线程的唯一标识
使用lua脚本保证加锁和设置锁过期时间、判断锁是当前线程上的锁和释放锁、判断锁是当前线程上的锁和为锁续期多步操作的原子性
分布式锁的不可重入也可能会导致死锁,用Hash数据类型,key作为锁唯一标识,key和field【uuid:线程id
】作为当前线程的唯一标识来做锁重入、锁释放和锁续期中锁属于当前线程的判断标识、以value作为锁重入次数的计数,该设计模仿可重入锁的锁重入实现方式,用lua脚本保证检查锁和操作锁多步操作的原子性
使用JDK的Timer定时器和lua脚本实现可重入锁的自动续期
在Redis主从集群下这种分布式锁可能会因为主机宕机,从机还没来得及同步上锁数据,从升级为主,另一个请求从新主中获取到锁导致锁机制失效,此时就需要使用红锁算法
锁内部操作实现的逻辑
加锁操作经过四个版本的迭代,其中上锁重试可以使用递归也可以使用循环重试
版本1:基于Redis一行指令setnx
上锁实现独占排他
问题:客户端宕机和不可重入引发死锁、不可重入、扩展功能原子性得不到保障
版本2:基于Redis的完整set <key> <value> [Ex seconds] [Px milliseconds] [NX|XX]
指令实现排他独占,上锁和设置锁有效时间两步操作的原子性
问题:不具备可重入性仍然存在死锁问题,引入锁有效期出现锁提前过期,错误上锁导致锁失效,锁被错误释放
版本3:基于Redis的Hash数据类型+lua脚本实现带有效时间解决锁错误释放的可重入锁,lua脚本的上锁逻辑是
1️⃣:根据key即锁的名称使用Redis指令exists
判断锁是否占用,如果对应key的记录不存在说明锁没有被占用,可以直接获取锁hset/hincrby
并设置锁过期时间expire
防客户端宕机导致死锁,用lua脚本保证以上操作的原子性
2️⃣:如果锁被占有则通过key和field【uuid:线程id
】判断当前线程是否占有锁的线程hexists
,是就进行锁重入hincby
并设置锁的过期时间expire
3️⃣:key对应名称的锁被占用且不是当前线程占用,获取锁失败,使用客户端代码进行重试
问题:没有解决锁提前失效的问题,多个线程可能同时运行导致锁失效
版本4:在版本3的基础上基于JDK的Timer定时器和lua脚本实现锁的自动续期,用延时单次定时任务和上一个定时调度线程成功执行才设置下一个定时续期调度线程的方式避开解决意外状况下的定时任务无法停止的问题,用lua脚本保证判断锁被当前线程占用并为锁续期两步操作的原子性
解锁操作经过三个版本的迭代
版本1:使用Redis单步指令del
删除键值对来释放锁
问题:del
指令不能删除键值对的同时检查键值对的value,可能导致误删
🔎:误删是删除非当前线程占有的锁
版本2:使用lua脚本保证多步Redis指令的原子性的前提下先判断value再删除键值对释放锁
问题:锁重入的情况下uuid
很难实现对同一把锁进行锁重入和重入后的释放
版本3:基于Redis的Hash数据类型+lua脚本在实现锁重入的基础上对value重入计数进行累加累减以及用uuid+线程id作为线程唯一标识实现对可重入锁的解锁,lua脚本的解锁逻辑为
1️⃣:判断锁是否当前线程占有的锁,不是直接返回nil,将来直接在程序中抛出异常
2️⃣:如果是当前线程占有的锁,对重入计数直接减1hincrby -1
,判断减完后的value是否为0,为0直接删除键值对释放锁,lua脚本返回删除键值对的执行结果,1表示释放成功
3️⃣:如果value值不为0则直接返回0
基于Redis的分布式锁现存问题
在现有实现的基础上仍然存在单点故障的问题,即目前的分布式锁只存在于单台Redis服务器上,如果该服务器挂掉,所有的分布式锁都会失效
🔎:企业开发一般都使用Redis集群,搭建Redis主从集群,配合哨兵机制在主挂掉的时候将从升级为主
Redis集群下的上述分布式锁面临的困难
Redis主从集群,对分布式锁的更新操作如上锁都在主中完成,然后主将更新操作通过IO操作记录到RDB日志或者AOF日志中,从从主中拉取日志需要发生网络IO操作,IO操作比较耗时,可能发生从还没有来得及同步更新操作日志主就宕机了,此时配合sentinel哨兵机制从升级为新的主,此时获取锁的线程正在执行业务方法,另一个请求如果也来获取锁,此时新升级的主中并没有同步成功对应的上锁操作,新的请求仍然能成功上锁并同时执行业务方法,此时就可能会发生并发线程安全问题
红锁算法是Redis官方提供的针对分布式锁集群下失效的算法机制,英文名为RedLock,红锁算法是Redis特有的,其他的应用中没有这种算法机制,Redis官方关于红锁的英文参考文档地址为:https://redis.io/topics/distlock
红锁算法的原理
🔎:这个红锁算法实现起来很麻烦,实际开发中很少使用,性能由于多redis节点串行获取锁也很难保证,对redis的集群设置也很偏门,一般互联网公司能搭建一个redis主从集群防单点故障就很不错了,这个老师只讲了实现步骤,没讲原理,没有对其具体实现
红锁算法下的Redis集群
红锁算法下的多个Redis节点没有主从关系也没有哨兵机制,相互之间完全独立,需要部署到不同的服务器或者虚拟机中,相互之间也不知道对方的存在
应用程序10010加锁过程
1️⃣:在获取锁以前应用程序需要获取系统当前时间作为获取锁的起始时间来计算从Redis集群中获取锁的总消耗时间
2️⃣:应用程序依次从所有节点上先后获取锁,使用set、setnx或者hset指令获取锁都可以,看业务要求,但是要保证所有节点都以同样的方式获取锁且需要保证键值对的key和value必须一样;依次获取锁的时候每个Redis节点都要设置超时时间,设置超时时间的目的是为了避免某个节点宕机,客户端一直尝试从该节点获取锁一直获取不到,如果超过指定时间获取不到就要去尝试从下一个节点获取锁,直到所有节点都尝试完了
3️⃣:计算所有节点获取锁消耗的时间,包括获取锁失败的节点的耗时时间,在锁没有自动续期的情况下,当获取锁的耗时小于锁的过期时间才任务锁获取成功【锁没有获取到不会进行续期动作】,且获取锁的耗时时间要远小于锁的过期时间,否则即使获取到锁也认为是获取锁失败了;在该前提下还要满足半数以上的节点【N / 2 + 1个】获取到锁才认为锁获取成功
4️⃣:获取到锁以后应用程序还要通过锁的有效时间减去获取锁总消耗时间得到锁的剩余有效时间,这也是实际锁的有效时间,剩余有效时间到了以后Redis集群自动释放锁
5️⃣:如果获取锁失败了【获取锁的消耗时间大于锁的有效时间或者少于半数的节点成功获取锁】,要对所有的节点释放锁,即使有些节点获取锁失败了该节点也需要释放锁,因为应用程序不知道哪些节点获取锁成功了,应用程序只知道有多少个节点获取锁成功了,所有节点都调用del
指令释放锁
应用程序10010解锁过程
1️⃣:解锁直接对每个节点释放锁使用del指令删除键值对释放锁即可,获取锁成功的直接删除锁,获取锁失败的删除锁失败也无所谓,根据业务场景需要决定是否使用lua脚本保证原子性或者防误删
Redisson类似于Jedis,功能封装相较于Jedis更加丰富,Jedis只是一个性能很好的Redis客户端,功能太弱;Redisson中封装了很多类似分布式锁、Java常用分布式对象【BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter】,常用分布式服务的实现;
这些分布式对象或者集合和单机版的一些对象集合在设计上考虑的点不一样,都是分布式场景下的Set、Map、List集合、队列,双端队列、双端阻塞队列、阻塞队列、信号量、分布式锁、原子整数等等
🔎:单机版的集合中只能放本地对象供本地操作,分布式环境下的集合可以存放分布式系统内的所有机器上的对象并放在如Redis中供大家共享操作
此外还有一些基于Redis实现的分布式远程服务
Redisson提供了很多使用Redis最方便简单的方法,和Jedis的设计理念不同,jedis的目的就是在客户端使用Redis指令,Redisson的目的是让用户不要关注redis本身和对应的指令,只关注使用Redisson实现业务逻辑
Redisson是一个在Redis的基础上实现的Java驻内存数据网格,内存数据网格的意思就是给内存做格式化,格式化的意思是给存储介质画格子,可以向格子中写入数据;格式化达到清空数据的效果是一种表面现象,实际动作是画格子【解释的一坨】,早期的U盘购买以后不能直接用,需要下驱动格式化才能使用,这个步骤就是给U盘画格子;格式化看上去清空了数据,实际上是在重新画格子【猜测是移除旧的寻址数据,设置新的写入空间,写入数据的时候设置新的寻址规则】
Redisson的官方文档:https://github.com/redisson/redisson/wiki
Redisson中的对锁自动续期的机制就叫看门狗机制
引入依赖
pom.xml
🔎:我严重怀疑有场景启动器,这里的配置是使用原生redisson的配置【经过后期确认,Maven仓库确实有对应的场景启动器依赖org.redisson.redisson-spring-boot-starter
,配置示例后面遇到再补充】
xxxxxxxxxx
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.1</version>
</dependency>
配置
Properties中没有提供对应的Redisson相关的配置项,不能在SpringBoot的默认配置文件中对Redisson进行配置,具体的配置方法可以参考文档的Configuration【配置方法】章节,可以通过代码、文件的方式对Redisson进行配置
Redisson可以通过用户提供的YAML格式的文本文件来进行配置,该YAML文件需要通过调用静态方法Config.fromYAML(new File("config-file.yaml"))
来创建Config配置类对象,通过配置类对象调用静态方法Redisson.create(Config config)
来实例化RedissonClient
对象,这个RedissonClient
对象就类似于操作Redis的StringRedisTemplate
,Redisson
通过该对象实现对Redis的所有操作,配置示例如下
xxxxxxxxxx
Config config = Config.fromYAML(new File("config-file.yaml"));
RedissonClient redisson = Redisson.create(config);
YAML的文件配置方式比较麻烦,程序化配置相对简单方便,程序化配置的方法是构造一个Config对象,调用对象的实例方法为该对象设置指定的参数,配置示例如下,推荐使用程序化配置方式
Redis地址必须以redis://
开头,后面跟redis服务器的ip和端口号
xxxxxxxxxx
Config config = new Config();
config.setTransportMode(TransportMode.EPOLL);
config.useClusterServers()
//可以用"rediss://"来启用SSL连接
.addNodeAddress("redis://127.0.0.1:7181");
配置Redis集群的模式
针对Redis以不同模式构建,RedissonClient的配置方式不同,但是也是大同小异,Redis常见的构建模式包括集群模式、云托管模式、单Redis节点模式、哨兵模式、主从模式、分片模式;配置对应模式的代码如下
【单机模式】
xxxxxxxxxx
Config config = new Config();
config.useSingleServer();
【分片模式】
参数addresses
是一个可变长度字符串类型参数,在分片模式下要传递多个Redis节点的地址
xxxxxxxxxx
Config config = new Config();
config.useClusterServers().addNodeAddress(String... addresses);
【自定义模式】
xxxxxxxxxx
Config config = new Config();
config.useCustomServers();
【主从模式】
xxxxxxxxxx
Config config = new Config();
config.useMasterSlaveServers();
【副本模式】
xxxxxxxxxx
Config config = new Config();
config.useReplicatedServers();
【哨兵模式】
xxxxxxxxxx
Config config = new Config();
config.useSentinelServers();
单Redis节点模式下的程序化配置方式
如果redis在本机,Redisson可以直接使用create方法以默认连接地址127.0.0.1:6379
初始化RedissonClient,示例如下
xxxxxxxxxx
// 默认连接地址 127.0.0.1:6379
RedissonClient redisson = Redisson.create();
如果redis不在本机,配置redis的模式、配置redis服务器地址和端口并用Redisson.create(config)
方法初始化RedissonClient,示例如下
xxxxxxxxxx
Config config = new Config();
//设置配置对应的Redis模式并设置对应的Redis地址
config.useSingleServer().setAddress("myredisserver:6379");
RedissonClient redisson = Redisson.create(config);
常用配置示例
xxxxxxxxxx
public class RedissonConfig{
public RedissonClient redissonClient(){
Config config = new Config();//初始化一个Redisson配置对象
config.useSingleServer()//使用单机模式
.setAddress("redis://192.168.200.132:6173")//指定Redis服务器地址
.setDatabase(0)//Redis默认有16个数据库,Redisson可以通过`setDatabase(int database)`方法进行指定,默认传参0表示使用第一个数据库
.setUsername(String username)
.setPassword()//设置用户名和密码,当redis设置了用户名和密码,对应的Config也需要配置
.setConnectionMinimumIdleSize(10)//设置连接池最小空闲连接数,生产环境最好设置,开发环境无需设置
.setConnectionPoolSize(50)//设置连接池最大线程数[这里怀疑是连接数不是线程数,因为连接池不一定需要使用线程池]
.setIdleConnectionTimeout(60000)//设置连接池线程的最大空闲时间,单位是毫秒,连接池线程空闲超过该时间就会被销毁直到线程数小于连接池最小空闲数
.setConnectionTimeout()//设置客户端程序获取redis连接的超时时间,如果超过该时间客户端还没有获取到redis连接就会快速失败
.setTimeout();//设置响应超时时间,如果超过指定时间还没有响应就快速失败
RedissonClient redisson = Redisson.create(config);
return redisson;
}
}
最小配置示例
这只是最简单的配置,定制化的配置还有很多,需要翻阅文档,在该配置下可以使用Redisson提供的:
🔎:同时也意味着其他的配置都是可选配置,但是老师说生产环境上面的常用配置项都需要进行配置
基于Redis的分布式锁RLock
Redisson
是接口RedissonClient
的实现类,Redisson的构造方法使用protected修饰,不能在包以外的地方调用,用户无法使用该构造方法;用户需要使用create方法来创建RedissonClient
对象
无参的create()
方法默认写死了本机的6379作为redis服务器地址,也是调用有参的create方法创建RedissonClient
对象
有参的create(Config config)
方法通过调用Redisson
的构造方法传参config
来创建RedissonClient
对象
xxxxxxxxxx
public class RedissonConfig{
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.200.132:6173");
RedissonClient redisson = Redisson.create(config);
return redisson;
}
}
等于没讲,空了自己总结,这里只是重点讲了一下获取分布式锁
RLock--->redissonClient.getLock(String name)
功能解析:获取一个名为name
的分布式锁对象
使用示例:RLock lock = redissonClient.getLock("lock")
示例含义:获取一个名为lock
的基于Redis的分布式锁对象
补充说明:
RBloomFilter<V>--->redissonClient.getBloomFilter(String name)
功能解析:获取布隆过滤器
使用示例:``
示例含义:
补充说明:
RBloomFilter<V>--->redissonClient.getBloomFilter(String name,Codec codec)
功能解析:获取布隆过滤器
使用示例:``
示例含义:
补充说明:
RAtomicDouble--->redissonClient.getAtomicDouble(String name)
功能解析:获取原子操作对象RAtomicDouble
使用示例:``
示例含义:
补充说明:
RAtomicLong--->redissonClient.getAtomicLong(String name)
功能解析:获取原子操作对象RAtomicLong
使用示例:``
示例含义:
补充说明:
RedissonLock
的底层原理就是上面基于Redis实现分布式锁的原理,只是相比于上面的实现多了CompletableFuture异步编排技术、反射式Reactive和RxJava2标准来实现的,解锁相较于上面多了一个自动续期和发布订阅,定时重置有效时间使用的netty的时间轮,早期也使用的JDK的Timer,说白了就是老师是仿Redisson的实现来演示自己实现一个基于Redis的分布式锁
除了RedissonLock
,Redisson在文档第八章分布式锁和同步器提到还提供了其他的锁,比如
这个延长锁的有效期机制被Redisson称为监控锁的看门狗,其实就是Timer定时器任务,默认情况下,锁的有效时间和我们自己设计的锁一样是30s,上锁10s以后就会重置一次锁有效时间,只要拿到锁的线程没有运行结束就会一直延长锁的有效期
雷丰阳老师说实际开发中更推荐使用redissonLock.lock(30,TimeUnit.SECONDS)
来明确指定锁自动释放时间,没给出理由,只是说这样可以省掉续期过程并且业务方法执行时间不可能超过30s,超出30s业务就完蛋了,执行完业务方法通过手动解锁的方式来释放锁,弹幕说实战推荐写一个注解,通过AOP加锁解锁
RLock
原理
RLock
的锁以用户获取锁传入的字符串作为key,以uuid作为filed,以重入次数作为value,同样使用Hash数据类型标识锁对象;RLock
也继承了JUC的Lock
接口,RLock
本身是一个接口
RLock
的lock方法被三个子类RedissonLock
、RedissonMultiLock
、RedissonSpinLock
实现了,这里只关注RedissonLock
对lock方法的实现逻辑,RedissonLock
继承自RedissonBaseLock
,RedissonBaseLock
实现了RLock
接口,即Redisson默认通过redissonClient.getLock("lock")
获取到的锁是RedissonLock
,其中的lock方法是对JUC的Lock接口的lock方法的实现,该锁本身就叫可重入锁,可重入原理也是和上面一样的
RedissonLock
的lock方法
该异步上锁方法tryLockInnerAsync
底层就是执行了一个Lua脚本,这个脚本和上面我们自己实现的基于Redis的分布式锁几乎是一模一样的
该lua脚本上锁的逻辑是如果锁没有被占用就获取锁并设置有效时间并返回nil;如果锁存在就判断是否是当前线程的锁,是就对重入次数加1重新设置有效时间并返回nil;如果获取不到锁就调用Redis的pttl
指令去获取键值对的毫秒级别过期时间
🔎:ttl指令获取的是秒级别的过期时间
上锁成功以后的自动续期会调用scheduleExpirationRenewal(threadId);
方法,最底层是在newTimeout
方法中使用netty
的时间轮实现io.netty.util.HashedWheelTimer
去做定时任务的,实际上Redisson
的早期版本使用的就是JUC里面的Timer
实现的定时重置过期时间,后面更新的版本中才将Timer
换成了netty
里面的时间轮;
续期脚本在renewExpirationAsync(long threadId)
方法中,也和我们此前的续期逻辑是一样的,先判断锁是否当前线程的锁,如果是就去使用Redis的pexpire指令执行毫秒级别精度的设置键值对的有效期,重置成功返回1,失败返回0;
而且也是使用的如果重置有效时间成功还会再去开启一次重置任务,当前任务定时任务会直接销毁的方式
这里面大量使用JUC提供的异步工具类CompletableFuture
进行异步编排,性能比我们实现的分布式锁要好很多
注意:Redisson
实现的RedissonLock
是阻塞锁,获取不到锁就直接阻塞等待了,直到当前线程释放锁才有机会再去抢占锁,不是采用循环重试的方式来抢占锁
xxxxxxxxxx
redisson.lock()
public void lock() {
try {
lock(-1, null, false);1️⃣ //该无参lock方法调用的是重载lock方法void lock(long leaseTime, TimeUnit unit, boolean interruptibly)
} catch (InterruptedException e) {
throw new IllegalStateException();
}
}
1️⃣ redisson.lock(-1, null, false)
//这里实际和ReentrantLock的实现类似,上锁都是在lock方法中去调用tryAcquire方法
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);1️⃣-1️⃣ //第一个参数的变量名是waitTime,注意这里面传入来的waitTime是-1,这个值是可以自定义设置的,就是Config这个类里面的lockWatchDogTimeout这个属性,代表锁的过期时间,如果没自定义设置就是30
// lock acquired,如果返回的ttl为null说明锁获取成功了,第一次尝试获取锁成功会直接返回
if (ttl == null) {
return;
}
CompletableFuture<RedissonLockEntry> future = subscribe(threadId);
pubSub.timeout(future);
RedissonLockEntry entry;
if (interruptibly) {
entry = commandExecutor.getInterrupted(future);
} else {
entry = commandExecutor.get(future);
}
try {
//如果第一次获取锁失败会进入死循环不停调用tryAcquire方法尝试获取锁,直到ttl等于null表示获取锁成功也会跳出循环直接返回
while (true) {
ttl = tryAcquire(-1, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
break;
}
// waiting for message
if (ttl >= 0) {
try {
entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
if (interruptibly) {
throw e;
}
entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
} else {
if (interruptibly) {
entry.getLatch().acquire();
} else {
entry.getLatch().acquireUninterruptibly();
}
}
}
} finally {
unsubscribe(entry, threadId);
}
// get(lockAsync(leaseTime, unit));
}
1️⃣-1️⃣ redissonLock.tryAcquire(-1, leaseTime, unit, threadId)
//tryAcquire方法调用的是Redisson实现的tryAcquireAsync(waitTime, leaseTime, unit, threadId)方法
private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
return get(tryAcquireAsync(waitTime, leaseTime, unit, threadId));1️⃣-1️⃣-1️⃣
}
1️⃣-1️⃣-1️⃣ redissonLock.tryAcquireAsync(waitTime, leaseTime, unit, threadId)
//在该方法中调用tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG)方法尝试异步加锁,这个是3.17.1版本的源码,和3.12.0版本的源码不同,应该是3.17.1进行了重写
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
if (leaseTime > 0) {
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
CompletionStage<Long> f = ttlRemainingFuture.thenApply(ttlRemaining -> {
// lock acquired
if (ttlRemaining == null) {
if (leaseTime > 0) {
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
scheduleExpirationRenewal(threadId);
}
}
return ttlRemaining;
});
return new CompletableFutureWrapper<>(f);
}
//该上锁方法底层就是执行了一个Lua脚本,这个脚本和上面我们自己实现的基于Redis的分布式锁几乎是一模一样的
//该lua脚本上锁的逻辑是如果锁没有被占用就获取锁并设置有效时间并返回nil;如果锁存在就判断是否是当前线程的锁,是就对重入次数加1重新设置有效时间并返回nil;如果获取不到锁就调用Redis的pttl指令去获取键值对的毫秒级别过期时间,ttl指令获取的是秒级别的过期时间
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}
//该方法继承自RedissonBaseLock,被tryAcquireAsync在成功获取锁后调用,方法名的意思是定时过期时间重置
//实现逻辑是先获取锁的信息,调用renewExpiration()方法来做锁续期
protected void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
entry.addThreadId(threadId);
try {
renewExpiration();
} finally {
if (Thread.currentThread().isInterrupted()) {
cancelExpirationRenewal(threadId);
}
}
}
}
//该方法继承自RedissonBaseLock,被scheduleExpirationRenewal方法调用,这就是执行定时过期时间重置的方法
//此前我们的实现是new Timer().schedule(new TimerTask(){})来使用jdk的Timer创建定时任务,这里使用的是newTimeout方法传参TimerTask来执行定时任务,
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
//实际是这一步去做定时任务的,通过下面的newTimeout方法源码分析可知是使用netty的时间轮实现io.netty.util.HashedWheelTimer去做定时任务的,定时任务的逻辑是在TimerTask中定义的
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
//这个renewExpirationAsync(threadId)方法里面有执行续期逻辑的lua脚本,这里面定义了续期的lua脚本
CompletionStage<Boolean> future = renewExpirationAsync(threadId);
future.whenComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getRawName() + " expiration", e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}
if (res) {
// reschedule itself
//如果重置有效时间成功还会再去开启一次重置任务,当前任务定时任务会直接销毁
renewExpiration();
} else {
cancelExpirationRenewal(null);
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
//该方法是接口ConnectionManager的抽象方法,被唯一实现类MasterSlaveConnectionManager实现了,该抽象方法只有一个实现,MasterSlaveConnectionManager有四个子类,全部调用的是父类该方法的实现,该方法被renewExpiration()方法调用
//这里有一个Debug技巧,遇到这种看方法源码的,直接使用快捷键Ctrl+Alt+B替代Ctrl+B跳转,如果是抽象方法运气好只有一个实现直接跳转不会跳转到抽象类或者接口中去,运气不好就到每个对应的实现去看吧,也可以打断点,但是搭环境不太方便
public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
try {
//与我们的实现不同,Redisson中的定时任务使用的是netty的io.netty.util.HashedWheelTimer时间轮实现的,Redisson的早期版本使用的就是JUC里面的Timer,后面更新的版本中才将Timer换成了netty里面的时间轮
return timer.newTimeout(task, delay, unit);
} catch (IllegalStateException e) {
if (isShuttingDown()) {
return DUMMY_TIMEOUT;
}
throw e;
}
}
//renewExpirationAsync(long threadId)方法被renewExpiration()调用,是RedissonBaseLock中的方法
//这个代码就是定义锁续期的lua脚本,续期逻辑是先判断锁是否当前线程的锁,如果是就去使用Redis的pexpire指令执行毫秒级别精度的设置键值对的有效期,重置成功返回1,失败返回0
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getRawName()),
internalLockLeaseTime, getLockName(threadId));
}
RedissonLock
的unlock方法
在redissonLock.unlockInnerAsync(threadId)
方法中定义了解锁的lua脚本,解锁逻辑是先判断锁是否当前线程的锁,不是当前线程的锁直接返回nil;如果是当前线程的锁,定义一个局部变量counter,该局部变量用于接收value减1后的值,如果减1后的计数counter
大于0说明锁没解完,重新设置锁的有效时间并返回0;如果减1后的计数counter
小于等于0直接删除键值对解锁,使用redis的指令publish
发布订阅以后直接返回1表示解锁成功;如果是其他情况仍然返回nil,和我们自己实现的原理是一样的,并在该方法中执行解锁操作
释放锁成功以后调用取消定时续期任务的方法cancelExpirationRenewal(threadId)
xxxxxxxxxx
//unlock()方法由用户通过rLock.unlock调用,该方法属于RedissonLock
//在该方法中调用父类RedissonBaseLock的unlockAsync(Thread.currentThread().getId()方法进行解锁
public void unlock() {
try {
get(unlockAsync(Thread.currentThread().getId()));
} catch (RedisException e) {
if (e.getCause() instanceof IllegalMonitorStateException) {
throw (IllegalMonitorStateException) e.getCause();
} else {
throw e;
}
}
// Future<Void> future = unlockAsync();
// future.awaitUninterruptibly();
// if (future.isSuccess()) {
// return;
// }
// if (future.cause() instanceof IllegalMonitorStateException) {
// throw (IllegalMonitorStateException)future.cause();
// }
// throw commandExecutor.convertException(future);
}
//redissonBaseLock.unlockAsync(long threadId)由子类redissonLock.unlock()方法调用
//redissonBaseLock.unlockAsync(long threadId)调用抽象方法子类实现redissonLock.unlockInnerAsync(threadId)来执行解锁
public RFuture<Void> unlockAsync(long threadId) {
//释放锁的方法
RFuture<Boolean> future = unlockInnerAsync(threadId);
CompletionStage<Void> f = future.handle((opStatus, e) -> {
//释放锁成功以后调用取消定时续期任务的方法cancelExpirationRenewal(threadId);
cancelExpirationRenewal(threadId);
if (e != null) {
throw new CompletionException(e);
}
if (opStatus == null) {
IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
+ id + " thread-id: " + threadId);
throw new CompletionException(cause);
}
return null;
});
return new CompletableFutureWrapper<>(f);
}
//redissonLock.unlockInnerAsync(threadId)由redissonLock.unlock()调用
//redissonLock.unlockInnerAsync(threadId)中定义了解锁的lua脚本,解锁逻辑是先判断锁是否当前线程的锁,不是当前线程的锁直接返回nil;如果是当前线程的锁,定义一个局部变量counter,该局部变量用于接收value减1后的值,如果减1后的计数counter大于0说明锁没解完,重新设置锁的有效时间并返回0;如果减1后的计数counter小于等于0直接删除键值对解锁,使用redis的指令publish发布订阅以后直接返回1表示解锁成功;如果是其他情况仍然返回nil,和我们自己实现的原理是一样的
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
用法示例1
最常用的就是RLock的无参加锁lock()
方法和unlock()
方法
这就是使用基于Redis的分布式锁的扣减库存完整代码,相当于将分布式锁封装好了,只需要使用lock方法上锁,unlock方法解锁,业务方法正常调用StringRedisTemplate
对redis中的共享数据进行操作即可就能实现并发线程安全
这个共享数据不一定是必须存redis中,分布式锁是限定所有请求都去竞争系统中都可以获取的唯一的锁,系统中所有要获取同一把锁的请求都串行执行业务方法
🔎:注意,老师还是在业务方法中使用StringRedisTemplate
来操作redis中的共享数据的,没有使用redissonClient
🔎:注意,前置工作还包括上面的引入Redisson依赖、配置RedissonClient
、实例化RedissonClient
对象并将该对象注入容器
xxxxxxxxxx
public void deduct() {
RLock lock = redissonClient.getLock("lock");
lock.lock();
try {
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
} finally {
lock.unlock();
}
}
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,使用Redisson的分布式锁并使用上述代码对redis中的共享库存数量5000进行单次扣减1,累计5000次扣减请求
测试结果:
1️⃣:没预热情况下吞吐量873,5000次扣减操作全部成功,Redis中库存数据应从5000减至0,实际是0,没有出现线程安全问题
结果分析:
1️⃣:性能比我们自己封装的基于Redis的分布式锁吞吐量600要好很多,这种专业框架底层会使用很多技术对性能做优化
用法示例2
RLock的重载lock方法rLock.lock(long leaseTime,TimeUnit unit)
的作用是当前线程获取分布式锁,给锁设定有效时间并指定时间单位,当超过有效时间时锁会自动释放,锁生效期间可以手动释放锁,一定注意这个方法是获取锁leaseTime
时间间隔以后自动释放锁,锁被释放以后再手动释放锁会抛异常
这里会存在一个问题,使用这个方法上锁,如果指定时间为10s自动释放,而锁自动续期时间间隔也是10s,如果使用该方法锁是不会自动续期的,如果此时业务方法还没有执行完,其他的请求线程就能抢占锁了,不仅会发生线程安全问题
调用rLock.lock(long leaseTime,TimeUnit unit)
方法根本不会执行到给锁续期的代码,在下面的1️⃣-1️⃣-1️⃣-1️⃣执行后就直接返回了,如果获取锁成功直接用用户指定时间作为锁的有效时间,到了有效时间就直接自动释放了,这可能可以解释为什么后面基于Zookeeper实现的分布式读写锁,用户的请求线程都已经响应了,但是读锁或者写锁仍然会等到指定时间到了以后才会被释放;
如果是无参数上锁方法rLock.lock()
没有指定锁的自动释放时间,会以org.redisson.config.Config
中看门狗超时时间属性private long lockWatchdogTimeout = 30 * 1000;
默认的30s作为锁的过期时间,并设置一个定时重置过期时间的任务,在下面1️⃣-1️⃣-1️⃣-2️⃣-1️⃣的newTimeout(new TimerTask() {}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
指定这个定时任务的触发时间是获取锁后1/3锁的有效时间即看门狗超时时间的三分之一即10s钟将锁的默认有效时间进行重置
加锁方法rLock.lock(long leaseTime,TimeUnit unit)
源码
xxxxxxxxxx
Redisson3.12.0
-----------------------------------------------------------------------------------------------------
redissonLock.lock(10,TimeUnit.SECONDS)
public void lock(long leaseTime, TimeUnit unit) {
try {
lock(leaseTime, unit, false);1️⃣ //调用重载方法加锁,注意这种加锁方式是不可打断的,默认interruptibly参数是false
} catch (InterruptedException e) {
throw new IllegalStateException();
}
}
1️⃣ redissonLock.lock(leaseTime, unit, false)
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
long threadId = Thread.currentThread().getId();//拿到当前线程的线程id并传递给获取锁的方法
Long ttl = tryAcquire(leaseTime, unit, threadId);1️⃣-1️⃣ //尝试一次获取锁,返回ttl为null表示获取锁成功,直接结束获取锁方法执行,如果第一次获取锁尝试失败,后续在while死循环中还会进行尝试
// lock acquired
if (ttl == null) {
return;
}
RFuture<RedissonLockEntry> future = subscribe(threadId);
if (interruptibly) {
commandExecutor.syncSubscriptionInterrupted(future);
} else {
commandExecutor.syncSubscription(future);
}
try {
while (true) {
ttl = tryAcquire(leaseTime, unit, threadId);//第一次获取锁失败这里还会尝试获取锁
// lock acquired
if (ttl == null) {
break;
}
// waiting for message
if (ttl >= 0) {
try {
future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
if (interruptibly) {
throw e;
}
future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
} else {
if (interruptibly) {
future.getNow().getLatch().acquire();
} else {
future.getNow().getLatch().acquireUninterruptibly();
}
}
}
} finally {
unsubscribe(future, threadId);
}
// get(lockAsync(leaseTime, unit));
}
1️⃣-1️⃣ redissonLock.tryAcquire(leaseTime, unit, threadId)
private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
return get(tryAcquireAsync(leaseTime, unit, threadId));1️⃣-1️⃣-1️⃣ //这里传递的时间leaseTime就是调用redissonLock.lock(10,TimeUnit.SECONDS)中的自动释放锁的锁有效时间,注意该方法和无参lock方法调用的tryAcquireAsync不是同一个方法,是重载方法
}
1️⃣-1️⃣-1️⃣ redissonLock.tryAcquireAsync(leaseTime, unit, threadId)
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
if (leaseTime != -1) {//如果传递了锁自动释放时间,此时leaseTime就为用户指定的时间,如果调用的是无参lock()方法没指定这个值默认应该为-1
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);1️⃣-1️⃣-1️⃣-1️⃣ //如果指定了锁自动释放时间就执行该tryLockInnerAsync方法,目的是尝试使用异步的方式加锁,上锁成功就直接返回了
}
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);//如果是无参lock()方法就会走这里的逻辑,该方法就是去redis中占锁,由于没有指定锁自动释放的时间,会使用配置Cfg中的LockWatchdogTimeout属性指定的时间,默认是30000ms,即看门狗超时时间也就是锁的默认有效时间30s,返回值是RFutrue对象,是JUC中的异步编排的内容,
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);1️⃣-1️⃣-1️⃣-2️⃣
}
});//如果占锁成功就会执行监听,参数e表示异常,e不为null即有异常,此时直接返回;如果没有异常且锁成功获取会调用scheduleExpirationRenewal(threadId);调度重置锁过期时间
return ttlRemainingFuture;
}
1️⃣-1️⃣-1️⃣-1️⃣ redissonLock.tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG)
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);//将获取锁后自动释放的时间间隔转换为毫秒级别的内部锁释放时间
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));//commandExecutor雷丰阳老师说是命令执行的线程池,并在此处将lua脚本发送给redis去异步执行,从这里发现,如果用户指定了锁自动释放时间,第一次上锁不管成功与否,上锁后都会直接返回,不会再执行后面的给锁续期的代码
}
1️⃣-1️⃣-1️⃣-2️⃣ redissonLock.scheduleExpirationRenewal(threadId)
private void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);//这一步是给Redis中放东西,但是放什么没说
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
entry.addThreadId(threadId);
renewExpiration();1️⃣-1️⃣-1️⃣-2️⃣-1️⃣ //重新给锁设置过期时间
}
}
1️⃣-1️⃣-1️⃣-2️⃣-1️⃣ redissonLock.renewExpiration()
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
RFuture<Boolean> future = renewExpirationAsync(threadId);1️⃣-1️⃣-1️⃣-2️⃣-1️⃣-1️⃣
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);
return;
}
if (res) {
// reschedule itself
renewExpiration();//注意这是递归调用本方法renewExpiration()
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);//获取一个连接管理器,调用newTimeout方法传参一个定时任务,定时任务就是run方法里面的内容,整段内容主要在运行renewExpirationAsync(threadId)方法中的代码
ee.setTimeout(task);
}
1️⃣-1️⃣-1️⃣-2️⃣-1️⃣-1️⃣ redissonLock.renewExpirationAsync(threadId)
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.<Object>singletonList(getName()),
internalLockLeaseTime, getLockName(threadId));//这个就是使用lua脚本执行给锁续期的代码,internalLockLeaseTime是Redisson的long类型参数,在Redisson构造的时候通过`this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();`仍然获取的是配置的看门狗超时时间,默认是30s,即这个脚本是重置锁的有效期时间到30s
}
用法示例3
rLock.tryLock(100,10,TimeUnit.SECONDS)
方法的作用是尝试上锁,最多等待100s的时间,100s还是获取不到就放弃抢占锁;如果上锁成功锁的有效期被设置为10s,到了10s以后会自动释放锁
FairLock是基于Redis的分布式可重入公平锁,实现了继承JUC的Lock接口的RLock接口,还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口,公平锁相对于RedissonLock的公平体现在所有请求线程都会在一个FIFO队列中排队,先发出请求的线程优先获取锁,而RedissonLock全凭运气
排队的线程所在服务器宕机了,Redisson会给该线程5秒钟的等待时间,到底是等谁,服务器都宕机了,线程还存在吗,这文档也写的逆天,那我只能猜测队列节点去尝试发起上锁请求结果等不到解锁回应,下一个节点对应线程就迟迟等待,而且服务器都宕机了,Redisson客户端都挂了,还哪里来的FIFO队列,这官方文档写的也是一坨
FairLock的使用方法和RedissonLock的使用方法是一样的,同时也有监控锁的看门狗机制,锁的有效时间还有10s一次重置都是一样的
代码示例
在请求参数中按顺序发起几个请求,发送时间间隔小于10s,每次请求都传参一个从1开始的整数id,每次请求递增1
最后的运行效果是,控制台每隔10s输出一个数字,输出数字的顺序是按照请求顺序依次输出1、2、3、4、5、6;说明确实是先进入阻塞队列的请求优先获取锁,证明了该锁的公平性;使用RedissonLock
重试一次就会发现id输出顺序是随机的
🔎:使用RedissonLock
测试的时候会发现一个请求同时被两个服务实例都接受到并都在控制台输出了相同的id,一个实例输出一次,这是nginx的分发机制导致的,因为这里一直重试迟迟获取不到锁,nginx等待超过一定时间得不到响应就会将请求进行重发,将nginx的请求重发禁用或者将超时时间设置的很长如proxy_connect_timeout 12000;
【设置连接超时时间】、proxy_send_timeout 12000;
【设置请求发送超时时间】、proxy_read_timeout 12000;
【设置响应超时时间】就不会出现这种情况了
对应的Redis中多了一个key为redisson_lock_queue(1)
的锁队列,其中保存着正在排队的线程信息,而且该队列是有顺序保证先进入队列的线程先获取锁
xxxxxxxxxx
public void testFairLock(Long id){
RLock fairLock = redissonClient.getFairLock("fairLock");
fairLock.lock();
try{
TimeUnit.SECONDS.sleep(10)
System.out.println(id);
}catch(InterruptedException e){
e.printStackTrace();
}finally{
fairLock.unlock();
}
}
概念:
联锁是需要至少三台Redis服务器,每个Redis服务器都对应的一个redissonClient实例,每个实例去对应的redis服务器中获取一把RLock锁,这些锁的名称最好是一样的,也可以不同;通过各个RedissonClient实例获取到指定名字的锁,通过联锁对象RedissonMultiLock
的构造方法传参三个锁对象【从这里看得出来,三个redissonClient实例都在一个客户端应用程序上,否则无法传参三个锁对象到一个方法里】返回一个RedissonMultiLock
锁对象,通过该对象的lock方法和unlock方法来实现加锁和解锁
用法示例
xxxxxxxxxx
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 所有的锁都上锁成功才算成功。
lock.lock();
...
lock.unlock();
特点
联锁通过redissonMultiLock
上锁成功的标志是联锁构造方法参数列表的每个锁都上锁成功,联锁才算上锁成功,只要有一个锁上锁失败,联锁就上锁失败;这意味着只要有对应的任何一台redis服务器宕机,这个联锁上锁就一定会失败,因此这个锁的使用场景很有限,尽量避免使用
概念
RedissonRedLock实现了Redis的红锁算法,该对象也可以将来自不同RedissonClient实例对象获取到的锁对象通过RedissonRedLockd的构造方法关联为一个红锁,对应的含义是从不同的Redis服务器获取到的锁关联为一个红锁对象,通过红锁对象的lock方法加锁、unlock方法解锁;
🔎:比联锁好在大部分节点加锁成功加锁就算成功,Redis给出的标准是大于节点数的一半加1,红锁了解一下就行,实际企业开发使用红锁的寥寥无几
用法示例
xxxxxxxxxx
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();
读读并发,读锁不会阻塞读锁,效果就和无锁是一样的,只会在redis中记录所有的读锁,并将mode字段设置为read
读写互斥,读锁阻塞写锁,写锁阻塞非当前线程读锁,JUC里面的读写锁支持先上写锁,相同线程还可以获取读锁,称为锁降级
写写互斥,占有写锁的线程会阻塞其他想要获取写锁的线程
概念
基于Redis的Redisson分布式可重入读写锁,该对象实现了JUC中的java.util.concurrent.locks.ReadWriteLock
接口,读锁和写锁都继承了RLock接口,JDK中的读写锁是读读并发,读写和写写互斥
读写不能并发设计目的是为了避免写的过程中读到脏数据,比如Mysql中的select for update,执行该语句会直接上悲观锁阻塞其他写操作,但是不会阻塞select操作,在修改还没有提交以前读取到旧数据,在特定场景下比如减库存的场景下就会导致超卖现象,因此有些场景下要控制不能读写并发;JUC的读写锁的实现原理是读锁是Shared类型的锁,写锁释放前检查AQS中的后续节点,如果是Shared类型的节点就释放连续一段Shared类型节点直到再次遇到要上写锁的独占类型节点,写锁等到所有的读锁都释放掉了才会被唤醒尝试获取锁
🔎:普通的锁也只是实现多线程写互斥,读操作如果不加锁,在写的过程中读操作不会被阻塞,即普通的锁也是读写并发的,因为使用普通的锁一般不会对读操作上锁,此时读就可能读到脏数据;但是读写锁读写也是互斥的,普通的锁做不到读写锁的效果
但是有些场景下对一致性要求不那么严格也有对应读写并发的实现,比如JUC中的CopyOnWriteArrayList
,支持多线程读单线程写,实现原理是更新操作通过复制一个新的数组来执行更新操作,读取操作还是在旧数组中进行
Redisson读写锁在redis中保存在key为rwLock的Hash数据中,Hash中为读锁时可以存在多条记录,但是为写锁时只能存在一条记录,第一行记录会保存一个field为mode的记录,value值为write或者read用来表示锁的类型
用法示例
【手动释放锁】
xxxxxxxxxx
//读写锁使用相同的名字在不同的方法中被不同线程调用是能锁住的,即只是通过名字作为锁的标识,不是通过锁对象来作为标识,因为名字标识一把锁,名字加服务加线程id标识不同的线程
RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock");
// 最常见的使用方法
rwlock.readLock().lock();
// 或
rwlock.writeLock().lock();
//解锁
rwlock.readLock().unlock();
// 或
rwlock.writeLock().unlock();
【自动释放锁】
xxxxxxxxxx
/ 10秒钟以后自动解锁
// 无需调用unlock方法手动解锁
rwlock.readLock().lock(10, TimeUnit.SECONDS);
// 或
rwlock.writeLock().lock(10, TimeUnit.SECONDS);
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = rwlock.readLock().tryLock(100, 10, TimeUnit.SECONDS);
// 或
boolean res = rwlock.writeLock().tryLock(100, 10, TimeUnit.SECONDS);
...
lock.unlock();
JUC中的信号量和Redisson中的信号量没有本质区别,只是JUC的是单机版本,Redisson是分布式版本,作用都是限制访问某个共享资源的线程数
🔎:JUC中的限制方式是用户在可能得目标线程中用Semaphore实例对象的acquire方法获取许可,Semaphore的构造方法传参int类型整数来指定最大允许同时运行的线程个数,获取许可时对应的state可允许运行的线程计数减一,通过其release方法来释放许可并让可允许运行的线程计数加1;当state减至小于0时当前线程就会进入AQS队列阻塞
🔎:信号量常用于请求限流这种场景,形象类似于停车场停车位是一定的,但是车辆数量可能超出停车位数量导致停车场的功能瘫痪这种类似的场景,请求限流是系统的并发能力有限,要限制请求线程的数量,系统中的请求处理完了释放资源后,后面的请求才能获取到许可继续执行
🔎:JUC里面的Semaphore只能限制单机情况下的线程数量,无法限制住分布式场景下的整体线程数,或者说效果太蹩脚,系统级别的限制被均摊到每个服务器上,不优雅;而且注意多个JUC的Semaphore是通过构造实例对象获取的,限制多个线程需要使用同一个Semaphore对象,即大多数场景下需要把Semaphore设置为成员变量
用法
代码示例
🔎:Redisson中的RSemaphore和JUC中的Semaphore的用法是一样的,获取许可也是acquire方法,释放许可也是release方法,该对象能限制系统内想要通过同名RSemaphore获取许可的线程数量,系统内超过许可数量的线程再想获取同名RSemaphore的许可会被阻塞
🔎:注意这个Semaphore是通过RedissonClient
获取的,即使在不同方法中获取,因为redissonClient
是单例的,所以只要Semaphore的名字是相同的,通过redissonClient.getSemaphore("semaphore");
获取到的Semaphore就是同一个实例对象,而且因为是基于第三方Redis实现的,即使线程不在一个服务也能通过名字共享一个Semaphore对象的信息
🔎:必须通过如semaphore.trySetPermits(3);
设置许可数量,否则默认许可是0会导致一个请求都进不来
🔎:使用RSemaphore会在redis中生成一个和预设名semaphore
相同的键值对,value是信号量的当前许可计数,但是特别注意,RSemaphore使用完以后,同名的RSemaphore键值对会一直保持第一次设置的允许同时运行的最大线程计数,因为Redis不知道该键值对何时还会再使用,因此不会删;这也会导致后续如果其他线程获取了同名RSemaphore且想通过semaphore.trySetPermits(5);
重新设置允许同时运行的最大线程计数是设置不了的,最大允许计数还会保留第一次设置的3,必须将Redis中的对应键值对删掉重新获取并设置允许同时运行的最大线程计数才行
xxxxxxxxxx
public void testSemaphore(){
RSemaphore semaphore = redissonClient.getSemaphore("semaphore");
//设置分布式系统中的许可数量,这行代码不写也不会报错,但是运行的时候因为没有指定许可数量一个请求都进不来,即可以认为默认许可数量是0
semaphore.trySetPermits(3);
try{
semaphore.acquire();
//业务代码
//手动释放许可,使后续请求能获取许可以执行业务
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
常用API
boolean ---> redissonSemaphore.tryAcquire()
功能解析:当前线程尝试获取运行许可,获取成功返回true
,获取失败返回false
,可以获取运行许可就占有一个运行许可,没有运行许可就放弃获取运行许可直接运行
使用示例:boolean acquire = semaphore.tryAcquire()
示例含义:当前线程尝试获取运行许可,没有运行许可就不拿了就直接运行,有运行许可就占有一个
补充说明:
该方法不像acquire方法一样会阻塞当前线程,获取不到运行许可也可以继续运行,但是我们可以根据该方法的返回值判断是继续执行业务方法还是直接返回友好提示,在限流系统中当信号量被占满,我们可以让无法获取信号量的用户请求线程直接返回,避免阻塞死等浪费系统资源
该对象在Semaphore的基础上扩展而来,作用是给每个许可增加一个过期时间,超过过期时间还没有释放许可,许可就会自动释放
❓释放完许可原来的线程还会继续运行吗?这点官方文档没说
🔑经过个人测试,这里许可自动释放以后原来被自动释放许可的线程会继续完整执行后面的业务逻辑,不会被阻塞,而且提前自动释放了许可再调用release(permitId)
方法释放许可会直接抛异常:
注意该对象手动释放许可必须传参获取许可的返回值permitId
,即使调用acquire方法没有指定许可有效时间手动释放许可也要传参permitId
验证代码及用法示例
代码示例:
xxxxxxxxxx
public void testPermitExpirableSemaphore(){
RPermitExpirableSemaphore semaphore = redissonClient.getPermitExpirableSemaphore("expirableSemaphore");
semaphore.trySetPermits(1);
String permitId = null;
try {
permitId = semaphore.acquire(2, TimeUnit.SECONDS);
new Thread(new Runnable() {
public void run() {
String acquire = null;
try {
acquire = semaphore.acquire(3,TimeUnit.SECONDS);
System.out.println("线程2开始执行... : " + System.currentTimeMillis());
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
semaphore.release(acquire);
System.out.println("线程2成功释放许可 : "+ System.currentTimeMillis());
}
}
}).start();
System.out.println("线程2已创建,线程1准备睡3s : "+System.currentTimeMillis());
TimeUnit.SECONDS.sleep(3);
System.out.println("线程1没有被阻塞 : "+ System.currentTimeMillis());
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
//semaphore.release(permitId);//锁自动释放以后手动释放锁会直接抛异常
System.out.println("线程1成功释放许可 : "+ System.currentTimeMillis());
}
}
运行效果
线程2已创建,线程1准备睡3s : 1724660727103
线程2开始执行... : 1724660729102
线程1没有被阻塞 : 1724660730118
线程1成功释放许可 : 1724660730118
线程2成功释放许可 : 1724660731106
类似JUC中的CountdownLatch
,构造CountdownLatch对象时通过有参构造传参计数值,一个线程调用countdownLatch.await()
方法会让当前线程进入阻塞,其他线程通过countdownLatch.countDown()
方法阻塞前让计数减1,直到计数减为0,被同一个CountdownLatch
对象的await方法阻塞的线程都同时开始继续运行,类似王者荣耀等全部用户加载完成主线程游戏开始这种应用场景,特别注意,这种方式与join的区别是执行完countdownLatch.countDown()
的线程还可以继续执行后续代码,而join必须等指定线程都彻底执行结束;CountdownLatch
适用于一个或者多个线程等待一组线程都执行完某个操作后执行的场景
❓:countdownLatch.await()
方法最多可以被一个线程调用还是多个线程一起调用,如果可以被多个线程调用多个线程是一起被唤醒
🔑:经过个人测试,可以多个线程一起调用同一个CountdownLatch
的await
方法,并且多个线程是一起被唤醒的,测试代码和结果如下
xxxxxxxxxx
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(2);
for (int i = 0; i < 3; i++) {
int j=i;
new Thread(() -> {
try {
System.out.println("线程"+j+"阻塞时间:"+System.currentTimeMillis());
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程"+j+"执行时间:"+System.currentTimeMillis());
}).start();
}
TimeUnit.SECONDS.sleep(2);
for (int k = 0; k < 2; k++) {
new Thread(()->{
countDownLatch.countDown();
System.out.println("计数减至0:"+System.currentTimeMillis());
}).start();
}
}
/**
*线程0阻塞时间:1724663790289
*线程2阻塞时间:1724663790289
*线程1阻塞时间:1724663790289
*计数减至0:1724663792305
*线程2执行时间:1724663792306
*线程0执行时间:1724663792306
*线程1执行时间:1724663792306
*计数减至0:1724663792305
*/
JUC的CountdownLatch
用于单机版的线程计数,只能识别单机中的线程的减计数,无法对微服务场景下的不同服务减计数同时进行感知;Redisson
的RCountdownLatch
也是通过名字在微服务环境对减计数进行感知
用法示例
代码示例
只要key的名字相同,所有微服务就能通过redis中的以自定义名称studentCount
来获取对应的键值对和计数信息,通过乐观锁机制进行减计数,因此能控制系统内多个服务的线程计数
注意当计数减为0以后redis中的键值对会消失
xxxxxxxxxx
public void testRLatch(){
RCountDownLatch studentCount = redissonClient.getCountDownLatch("studentCount");
studentCount.trySetCount(6);
//一顿准备锁门操作完成准备锁教师门
try {
//锁门确认
studentCount.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void testRCountDown(){
RCountDownLatch studentCount = redissonClient.getCountDownLatch("studentCount");
//学生出门操作
//出门确认计数减1
studentCount.countDown();
}
【控制器方法】
班长先锁门被阻塞,当出门请求处理6次以后班长锁门放行
xxxxxxxxxx
"test/latch") (
public String testLatch(){
this.stockService.testLatch();
return "班长锁门。。。";
}
"test/countdown") (
public String testCountDown(){
this.stockService.testCountDown();
return "出来了一位同学";
}
大数据领域的Hadoop和HBase生态需要使用Zookeeper做分布式协调,Java中使用Zookeeper相较于大数据要少,使用Dubbo微服务框架也可能会使用到Zookeeper,Zookeeper是分布式应用程序协调服务,是为分布式应用提供一致性服务、配置维护【配置中心】、域名维护【注册中心】、分布式同步、组服务的软件;Zookeeper出于安全考虑一般在生产环境就是直接搭建集群
Zookeeper有一套指令集,即原语集。提供了Java和C的客户端接口,可以通过Java客户端来调用Zookeeper中的指令
Zookeeper一共分布式独享锁、与Redis和Mysql不同,Zookeeper的主是内部自己选举出来的,Redis和Mysql主从集群的主是用户自己指定的
基于Zookeeper实现的分布式锁相对没有基于Redis实现的分布式锁那么流行,但是还是有百分之二三十的市场份额
Zookeeper安装、指令、节点类型相关内容参见Linux指南--安装Zookeeper
Zookeeper的Java客户端主要有三个,一种是官方提供的客户端、此外还有第三方提供的Java客户端ZkClient、Curator
ZkClient是一个小的开源社区开源的,对官方客户端底层做了很多优化,相对于官方客户端使用起来更舒服;很多框架底层都集成了ZkClient,比如微服务框架Dubbo、消息中间件Kafka
Curator做了很多高级封装,比如封装了基于Zookeeper实现的分布式锁,有点类似于Redisson,功能更强大,很多应用类公司更喜欢使用Curator
本次讲解主要演示使用官方客户端实现一个基于Zookeeper实现的分布式锁,并使用Curator中已经封装好的分布式锁
使用官方Zookeeper客户端
引入依赖
在zookeeper的客户端中已经引入了slf4j-log4j12
,如果已经在其他地方也引入就会有Slf4j
日志标红提示,老师的解决办法是从zookeeper的依赖中移除slf4j-log4j12
我认为这里老师讲的是错误的,因为maven会自动处理重复的依赖项,除非两个相同依赖的版本不一致,另一方面从依赖树形结构图中没有找到期望被移除的slf4j-log4j12
,从报错信息上来看是logback-classic1.2.11
中的org.slf4j.impl.StaticLoggerBinder.class
和slf4j-reload4j.1.7.36
中的org.slf4j.impl.StaticLoggerBinder.class
两个类发生了冲突,通过网络搜索发现网上那个和slf4j-log4j12
冲突的报错信息确实是slf4j-log4j12-1.7.25.jar
,总之就是logback和log4j之间关于org/slf4j/impl/StaticLoggerBinder.class
这个类发生的冲突,不同jar包下的相同的全限定类名的类在不破坏JVM的双亲委派模型类加载机制情况下全限定类名相同的类只会加载被先加载的jar包中的对应类,jar包的加载顺序和classpath
参数有关,包路径越靠前越先被加载,加载顺序靠后的jar包中的全限定类名相同的类会被直接忽略掉不会再被加载,SpringBoot
的默认日志是logback
,log4j
是以前的主流日志,很多第三方工具包都使用的是log4j
,解决办法是排除logback
或者log4j
的其中一个,让整个项目使用其中的一种日志;【具体原因还需要深入分析】
【报错信息】
xxxxxxxxxx
SLF4J Class path contains multiple SLF4J bindings.
SLF4J Found binding in jar file /D /maven-repository/ch/qos/logback/logback-classic/1.2.11/logback-classic-1.2.11.jar!/org/slf4j/impl/StaticLoggerBinder.class
SLF4J Found binding in jar file /D /maven-repository/org/slf4j/slf4j-reload4j/1.7.36/slf4j-reload4j-1.7.36.jar!/org/slf4j/impl/StaticLoggerBinder.class
SLF4J See http //www.slf4j.org/codes.html#multiple_bindings for an explanation.
SLF4J Actual binding is of type ch.qos.logback.classic.util.ContextSelectorStaticBinder
【springboot排除logback依赖实例】
排除logback
就要把项目中所有的logback
都排除,只使用log4j
xxxxxxxxxx
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<exclusions>
<!-- 排除自带的logback依赖 -->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<!-- 排除自带的logback依赖 -->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
【正常依赖】
xxxxxxxxxx
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.7.0</version>
</dependency>
【老师的排除slf4j-log4j12
示例】
xxxxxxxxxx
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.7.0</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
</dependency>
【实测排除slf4j-reload4j
也行】
xxxxxxxxxx
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.7.0</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-reload4j</artifactId>
</exclusion>
</exclusions>
</dependency>
zookeeper客户端对象的获取
Zookeeper
对象通过构造方法创建,构造方法只能是有参构造,必须传参连接字符串connectString
、连接超时时间sessionTimeOut
、监听器watcher
,该对象使用完以后必须调用close
方法来手动关闭连接,注意关闭连接就相当于客户端已经断开连接了,该客户端创建的临时节点都会被zookeeper服务器删除
连接字符串connectString
:格式必须为"127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002"
,参数值是Zookeeper服务器集群的地址
用逗号分隔各地址,注意逗号两边不能有空格
连接超时时间sessionTimeOut
:参数为int
类型,单位是毫秒
监听器Watcher
:一个接口,需要使用匿名内部类的方式重写process
方法来实例化对象,process
方法会在连接建立时和连接关闭时各执行一次
注意调用完Zookeeper的构造方法以后还在获取连接程序就会执行后续的代码,此时zookeeper对象只是赋值了对象地址因为建立连接较慢还没有完成初始化,其中的功能是无法正常使用,此时需要使用闭锁CountdownLatch
来实现对zookeeper初始化进行等待的效果
zookeeper对象的初始化示例
xxxxxxxxxx
public static void main(String[] args) {
ZooKeeper zooKeeper = null;
try {
//Zookeeper操作对象可以直接通过构造方法创建,构造方法只能是有参构造,必须传参连接字符串connectString、sessionTimeOut、watcher
//连接字符串connectString的格式必须为"127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002",参数值是Zookeeper服务器集群的地址,用逗号分隔各地址,注意逗号两边不能有空格
//参数sessionTimeOut是int类型的连接超时时间,单位是毫秒
//监听器Watcher是一个接口,需要使用匿名内部类的方式来实例化对象
//使用完客户端对象以后调用close方法来关闭该客户端
zooKeeper = new ZooKeeper("192.168.200.132:2181", 30000, new Watcher() {
public void process(WatchedEvent event) {
System.out.println("此时才真正获取连接进行回调process方法,该方法会在获取Zookeeper连接和关闭Zookeeper连接的时候分别调用一次,因此一共会调用两次");
}
});
//Zookeeper对象的Api
//zookeeper.create方法能创建节点
//zookeeper.exist方法能判断某个节点是否存在
//zookeeper.getChildren方法能获取节点的子节点和数据内容
//注意,Zookeeper在获取连接的时候,调用获取Zookeeper对象的方法后续代码就已经在执行了
//这里经过测试zookeeper不是null,这里是老师讲错了,根据以往JUC里面学到的知识认为是赋值操作已经完成,但是还没有初始化好,实际该对象虽然不是null但是需要在建立好连接后才能使用
System.out.println("此时还在建立连接,Zookeeper仍然为null但是这里已经能够执行了");
System.out.println(zooKeeper==null);
/**执行效果
* 此时还在建立连接,Zookeeper仍然为null但是这里已经能够执行了
* false
* 此时才真正获取连接进行回调process方法,该方法会在获取Zookeeper连接和关闭Zookeeper连接的时候分别调用一次,因此一共会调用两次
* 此时才真正获取连接进行回调process方法,该方法会在获取Zookeeper连接和关闭Zookeeper连接的时候分别调用一次,因此一共会调用两次
* */
//Zookeeper客户端中引入了Slf4j
} catch (IOException e) {
e.printStackTrace();
}finally {
if (zooKeeper != null) {
try {
zooKeeper.close();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
使用闭锁CountdownLatch
等待zookeeper对象初始化完成
注意,两次process方法回调时传参的WatchedEvent
对象的state
属性值是不同的,第一次获取连接是SyncConnected
,关闭连接时是Closed
,可以根据两个属性值来区分是获取连接还是关闭连接,该属性值的类型是枚举Event.keeperState
,可以通过该属性值和枚举值对比决定是否需要放行countDownLatch.await()
从而继续执行获取到zookeeper连接后的动作
❓:不是异步获取连接吗为什么这里属性值是同步连接
xxxxxxxxxx
/**执行效果
* 此时才真正获取连接进行回调process方法,该方法会在获取Zookeeper连接和关闭Zookeeper连接的时候分别调用一次,因此一共会调用两次
* 此时还在建立连接,Zookeeper已赋值对象地址但是对象还没完成初始化,此时这里已经能够执行了
* false
* WatchedEvent state:SyncConnected type:None path:null
* 此时才真正获取连接进行回调process方法,该方法会在获取Zookeeper连接和关闭Zookeeper连接的时候分别调用一次,因此一共会调用两次
* WatchedEvent state:Closed type:None path:null
* */
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(1);
ZooKeeper zooKeeper = null;
try {
zooKeeper = new ZooKeeper("192.168.200.132:2181", 30000, new Watcher() {
public void process(WatchedEvent event) {
countDownLatch.countDown();
System.out.println("此时才真正获取连接进行回调process方法,该方法会在获取Zookeeper连接和关闭Zookeeper连接的时候分别调用一次,因此一共会调用两次");
System.out.println(event);
}
});
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("此时还在建立连接,Zookeeper以赋值对象地址但是对象还没完成初始化,此时这里已经能够执行了");
System.out.println(zooKeeper==null);
} catch (IOException e) {
e.printStackTrace();
}finally {
if (zooKeeper != null) {
try {
zooKeeper.close();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
【优化后的通用模板代码】
xxxxxxxxxx
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(1);
ZooKeeper zooKeeper = null;
try {
zooKeeper = new ZooKeeper("192.168.200.132:2181", 30000, new Watcher() {
public void process(WatchedEvent event) {
if(Event.KeeperState.SyncConnected.equals(event.getState())){
countDownLatch.countDown();
}
}
});
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("业务操作");
} catch (IOException e) {
e.printStackTrace();
}finally {
if (zooKeeper != null) {
try {
zooKeeper.close();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
上述代码还不够完善,因为节点事件的监听回调依然会执行process()
方法,此时process
方法传参的event
和获取连接时回调process
方法的event
分别为WatchedEvent state:SyncConnected type:NodeChildrenChanged path:/earl
[1]和WatchedEvent state:SyncConnected type:None path:null
;注意这些event
中的state
属性为SyncConnected
,和关闭连接时的WatchedEvent state:Closed type:None path:null
该state
属性Closed
不同,由此区分关闭连接和其他事件;可以通过event
参数的type
属性来区分事件类型从而执行不同的回调逻辑,在state
属性为SyncConnected
的前提下,当type
为None
时表明回调由成功获取连接发起,当type
属性为事件类型时表明回调由事件发起;此外注意event
中的path
存储了事件监听节点的路径,通过该路径可以制定不同节点的事件回调逻辑;由此可以将节点事件回调分成获取连接、关闭连接、节点事件三个大类执行对应的回调逻辑,对节点事件可以通过事件节点路径来区分执行不同的回调逻辑,示例代码如下
在Zookeeper对象的构造方法传参watcher对象中通过event
对象的state
属性、type
属性和path
属性来分区获取连接回调、关闭连接回调和不同类型不同节点的节点事件回调
节点事件回调直接在Zookeeper构造方法传参中写一起不优雅,不方便读和改,判断逻辑复杂;业界常用的方式是在节点事件监听方法中传参Watcher
匿名实现重写process
方法来自定义节点事件的回调逻辑,连续回调需要对节点事件方法进行封装,通过在回调方法中递归调用该方法来实现连续回调
xxxxxxxxxx
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(1);
ZooKeeper zooKeeper = null;
try {
zooKeeper = new ZooKeeper("192.168.200.132:2181", 30000, new Watcher() {
public void process(WatchedEvent event) {
Event.KeeperState eventState = event.getState();
if (Event.KeeperState.SyncConnected.equals(eventState) && Event.EventType.None.equals(event.getType())) {
//获取连接回调逻辑,通常让闭锁计数减1来放行主业务执行或者其他逻辑
countDownLatch.countDown();
} else if (Event.KeeperState.Closed.equals(eventState)) {
//关闭连接后的回调业务逻辑
//} else {
//节点事件回调逻辑,可以根据事件类型和节点路径进一步区分具体节点不同事件类型的回调逻辑,这些事件回调都是一次性的,后续相同事件发生不会再回调,想要后续继续回调可以在回调事件中再调用对应的事件比如getChildren("/earl",true)来实现,但是这种在一个process方法中写完所有回调逻辑的方式不优雅;回调的业务逻辑一般不写在该process方法中,一般是在业务方法中通过重载方法getChildren("/earl",watcher)传参一个Watcher类型的匿名实现来替换布尔类型的watch变量,在匿名实现需要重写的process方法中去自定义回调逻辑,这种方式更方便代码的组织和修改,读起来也更容易,如下业务代码所示
//}
}
});
countDownLatch.await();
System.out.println("业务操作");
//常用的事件监听和回调方式
List<String> children = zooKeeper.getChildren("/earl", new Watcher() {
public void process(WatchedEvent event) {
System.out.println("节点/earl的子节点发生变化触发的回调");
//如果需要多次回调,回调的方式一般是把该监听方法封装成一个单独的方法,在该方法的回调中递归调用方法本身,如果只是一次回调则只需要这种实现即可
}
});
//这个等待回调的方式有点野蛮,感觉JUC里面的保护性暂停用在这里很不错;实际开发不会这么用,一般是系统启动就创建好对应的Zookeeper对象并保持连接不会关闭知道系统关机,因此这里的对节点事件的监听回调一般都使用闭锁来进行阻塞,在回调中让闭锁计数减1来放行阻塞线程
System.in.read();
} catch (IOException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (zooKeeper != null) {
try {
zooKeeper.close();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
String ---> zookeeper.create(final String path,byte[] data,List<ACL> acl,CreateMode createMode) throws KeeperException, InterruptedException
功能解析:创建指定数据、指定数据下的节点,返回值为被创建节点的路径
使用示例:zooKeeper.create("/earl/testJavaClient", "Hello zookeeper".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
示例含义:创建一个路径为/earl/testJavaClient
,数据内容为Hello zookeeper
,允许所有客户端对该节点进行任何操作的永久节点
补充说明:
数据内容data
要求传参一个byte
数组,可以通过字符串.getBytes()
方法获取对应的byte数组
权限List<ACL>
有专门的枚举类ZooDefs.Ids
,常用的三种权限包括
ZooDefs.Ids.OPEN_ACL_UNSAFE
:所有客户端都可以对创建的节点做任何操作
ZooDefs.Ids.CREATOR_ALL_ACL
:创建节点的客户端可以对节点做任何操作
ZooDefs.Ids.READ_ACL_UNSAFE
:所有客户端都能对创建的节点做读取操作
节点类型createMode
也使用专门的枚举类CreateMode
,对应节点类型有以下四种
CreateMode.PERSISTENT
:创建的节点是永久节点
CreateMode.EPHEMERAL
:创建的节点是临时节点,这种方式创建的临时节点在调用zookeeper.close()
方法后节点会直接被zookeeper服务器秒删
CreateMode.EPHEMERAL_SEQUENTIAL
:创建的节点是序列化临时节点
CreateMode.PERSISTENT_SEQUENTIAL
:创建的节点是序列化永久节点
Stat ---> zookeeper.exists(String path, boolean watch) throws KeeperException, InterruptedException
功能解析:判断指定路径节点是否存在,如果返回值为null
说明对应节点不存在,如果返回值不为null
说明对应的节点存在;第一个参数是指定节点路径,第二个参数是指定是否要监听,指定true
表示要监听时间,指定false
表示不监听
使用示例:zooKeeper.exists("/earl/testJavaClient", false);
示例含义:查询路径为/earl/testJavaClient
的节点是否存在
补充说明:
exists
方法相当于zookeeper中的stat
指令,可以通过该方法的重载方法来做对节点删除和节点创建事件的监听
byte[] ---> zookeeper.getData(String path, boolean watch, Stat stat) throws KeeperException, InterruptedException
功能解析:查询已经存在的指定路径节点的内容数据,这里传参stat
是zooKeeper.exists("/earl/testJavaClient", false);
的返回值,暂时认为要查询指定节点的内容数据必须先查询该节点是否存在。第二个参数是指定是否要监听,指定true
表示要监听时间,指定false
表示不监听
使用示例:zooKeeper.getData("/earl/testJavaClient", false, exists);
示例含义:查询已经存在节点/earl/testJavaClient
的数据内容
补充说明:
getData
方法相当于zookeeper中的get
指令,可以通过该方法的重载方法来做对节点的数据变化监听
List<String> ---> zookeeper.getChildren(String path, boolean watch) throws KeeperException, InterruptedException
功能解析:查询一个指定节点下的全部子节点
使用示例:zooKeeper.getChildren("/earl", false);
示例含义:查询节点/earl
下的所有子节点
补充说明:
getChildren
方法相当于zookeeper中的ls
指令,可以通过该方法的重载方法来做对子节点的创建、删除、数据内容变化监听
Stat ---> zookeeper.setData(final String path, byte[] data, int version) throws KeeperException, InterruptedException
功能解析:更新一个指定节点的数据内容,第三个参数version
需要使用exists
方法查询获取Stat
返回值,用stat.getVersion()
来获取指定路径节点的版本号,如果更新时发现当前数据版本号和传参不一致,更新操作就会失败;即更新操作也需要事先查询指定节点是否存在且获取Stat
返回值作为更新方法传参
使用示例:zooKeeper.setData("/earl/testJavaClient", "hello earl".getBytes(), exists.getVersion())
示例含义:把节点/earl/testJavaClient
下的数据内容更新为hello earl
补充说明:
版本号也可以指定为-1,表示更新操作不关心版本号,本次更新操作一定会成功
void ---> zookeeper.delete(final String path, int version) throws InterruptedException, KeeperException
功能解析:删除指定路径节点,如果当前版本号和指定版本号不一致则删除失败
使用示例:zooKeeper.delete("earl/testJavaClient",exists.getVersion());
示例含义:删除节点/earl/testJavaClient
补充说明:
版本号也可以指定为-1,表示删除操作不关心版本号,本次删除操作一定会成功
如果要删除的节点不存在仍然执行了该方法会抛出异常,注意凡是涉及到事件监听的方法调用,调用事件监听方法的线程在事件发生前不能提前结束执行,事件发生时线程运行结束会导致无法执行事件监听成功后的回调,因此调用事件监听方法的线程不能在事件发生前结束,不能在等待期间发生异常,发生了异常如果没有捕获处理也会直接结束当前线程
常用的分布式锁的特点是独占排他、Redis通过使用setnx指令来实现只有一个请求线程能成功创建指定key的键值对;Zookeeper可以使用相同路径zNode节点只能创建一个的特点来实现,多个请求线程都去创建同一个路径下的节点,最终只会有一个请求线程成功创建对应的节点,执行完业务方法通过删除该节点的方式来释放锁,后续请求就能去竞争创建该节点来获取锁
基础实现
原理:
通过组件标注了@PostConstruct
注解的init()
方法可以在项目启动时就去创建Zookeeper客户端对象,Zookeeper的连接属于长连接,项目正常运行期间不需要关闭连接;通过组件中标注了@PreDestory
注解的destory()
方法在Spring容器销毁前执行对应的释放连接的代码,这样就实现了不用每次获取锁都去重新建立连接,虽然第一次启动会变慢,但是用户感觉不到,且能提升分布式锁的性能
🔎:项目启动时初始化Spring
容器,此时会去扫描标注了注解@Component
的类,通过对应类的无参构造方法来初始化对应的对象,在init
方法上标注了@PostConstruct
注解,该init
方法会在类的无参构造方法执行以后立即执行,通过这种方式可以保证项目启动时就会立即执行init()
中的方法;通过标注了@PreDestory
注解的destory()
方法可以实现在容器组件销毁前执行其中如释放连接的代码
每次去获取锁对象都去检查Zookeeper中锁的根节点/locks
是否存在,避免创建锁节点/locks/lockName
时因为父节点不存在而连续失败
Zookeeper的临时节点天然就能解决因服务器宕机导致的死锁问题,因为服务器宕机会导致客户端和Zookeeper的连接断开,此时Zookeeper服务器就会自动删除对应客户端的临时节点,从而达到服务器宕机自动释放锁的效果,因此基于Zookeeper实现的分布式锁不需要考虑给锁设置有效时间防服务器宕机死锁的问题,也不需要考虑使用定时任务来给锁不断续期的问题
释放锁不需要考虑版本号直接删除对应节点即可
代码示例
【分布式锁工厂类】
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 分布式锁工厂类
* 获取基于Redis的分布式锁 DistributedRedisLock、基于Zookeeper的分布式锁DistributedZookeeperLock
* @创建日期 2024/08/20
* @since 1.0.0
*/
public class DistributedLockClient {
private ZooKeeper zooKeeper;
private String uuid;
private StringRedisTemplate redisTemplate;
public void init(){
CountDownLatch countDownLatch = new CountDownLatch(1);
try {
zooKeeper = new ZooKeeper("192.168.200.132:2181", 30000, new Watcher() {
public void process(WatchedEvent event) {
Event.KeeperState eventState = event.getState();
if (Event.KeeperState.SyncConnected.equals(eventState) && Event.EventType.None.equals(event.getType())) {
//获取连接回调逻辑,通常让闭锁计数减1来放行主业务执行或者其他逻辑
countDownLatch.countDown();
} else if (Event.KeeperState.Closed.equals(eventState)) {
//关闭连接后的回调业务逻辑
}
}
});
} catch (IOException e) {
e.printStackTrace();
}
}
public void destroy(){
try {
if (zooKeeper != null){
zooKeeper.close();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public DistributedLockClient() {
this.uuid = UUID.randomUUID().toString();
}
public DistributedRedisLock getRedisLock(String lockName){
return new DistributedRedisLock(redisTemplate, lockName, uuid);
}
public DistributedZookeeperLock getZookeeperLock(String lockName){
return new DistributedZookeeperLock(zooKeeper,lockName);
}
}
【基于Zookeeper实现的分布式锁】
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 基于Zookeeper和Zookeeper的官方客户端实现的分布式锁
* @创建日期 2024/08/28
* @since 1.0.0
*/
public class DistributedZookeeperLock implements Lock {
private ZooKeeper zooKeeper;
/**
*锁的相关节点不要直接创建到根节点`/`下,全部创建到`/locks`节点下,即锁的路径为`/locks/lockName`
*/
private String lockName;
/**
*指定所有锁的父节点
*/
private static final String LOCKS_ROOT_PATH="/locks";
/**
* @param zooKeeper
* @param lockName
* @描述 初始化锁对象只是检查有没有对应的/locks节点,通过锁对象的lock方法来竞争创建对应节点获取对应的锁
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
*/
public DistributedZookeeperLock(ZooKeeper zooKeeper, String lockName) {
this.zooKeeper=zooKeeper;
this.lockName=lockName;
//每次获取锁都去检查是否有锁的根节点,没有根节点就创建锁的根节点"/locks",因为在没有父节点`/locks`的前提下创建子节点`/locks/lockName`会直接失败
try {
if (zooKeeper.exists(LOCKS_ROOT_PATH,false)==null) {
zooKeeper.create(LOCKS_ROOT_PATH,null,ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.PERSISTENT);
}
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* @描述 加锁通过调用tryLock方法实现
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
*/
public void lock() {
tryLock();
}
public void lockInterruptibly() throws InterruptedException {
}
/**
* @return boolean
* @描述 加锁方法尝试创建`/locks/lockName`节点,创建节点失败就等待重试创建节点的过程
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
*/
public boolean tryLock() {
try {
//创建ZNode节点`locks/lockName`,注意Zookeeper中的临时节点能天然处理因服务器宕机导致的死锁问题,没有解锁但是应用程序已经挂掉
// 连接断掉,临时节点马上就会被删除,锁也会被立即释放,这样就能避免因服务器宕机导致的死锁问题
//Redis通过给锁添加有效时间然后用定时任务不停为锁续期来解决该问题,Zookeeper就不需要考虑该问题
zooKeeper.create(LOCKS_ROOT_PATH+"/"+lockName,null,ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.EPHEMERAL);
return true;
} catch (Exception e) {
//创建节点失败会抛异常,我们可以捕获异常然后进行等待重试,重试可以使用递归调用或者循环重试
e.printStackTrace();
try {
Thread.sleep(80);
tryLock();
} catch (InterruptedException interruptedException) {
interruptedException.printStackTrace();
}
}
//有任何原因导致执行到这儿说明获取锁失败,返回false
return false;
}
public boolean tryLock(long time, TimeUnit unit) {
return false;
}
/**
* @描述 尝试删除`/locks/lockName`节点
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
*/
public void unlock() {
try {
zooKeeper.delete(LOCKS_ROOT_PATH+"/"+lockName,-1);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (KeeperException e) {
e.printStackTrace();
}
}
public Condition newCondition() {
return null;
}
}
【业务代码】
xxxxxxxxxx
public String deduct() {
DistributedZookeeperLock stockLock = distributedLockClient.getZookeeperLock("stockLock");
stockLock.lock();
try {
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
} finally {
stockLock.unlock();
}
return "库存扣减成功!";
}
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,使用上诉基于Zookeeper临时节点实现的不可重入分布式锁对库存数量5000进行单次扣减1,累计5000次扣减请求
测试结果:
1️⃣:吞吐量300,系统预热后压测性能也只有380,5000次扣减操作全部成功,Redis中库存数据应从5000减至0,实际是0,没有出现线程安全问题
请求线程获取锁失败会等待并自旋重试,这种方式在高并发情况下竞争激烈导致重试不停地失败,并发度越高竞争越激烈性能就越低,在Zookeeper中可以通过临时序列化节点和节点事件监听来实现阻塞锁
使用基于Zookeeper实现的分布式锁一定要时时注意项目运行前一定要删除锁目录下的所有锁,一旦有一个锁创建时发生重名且没有线程占有该锁,该锁永远不会释放,就会发生死锁现象
原理:
通过序列化临时节点可以让每个创建锁节点的线程都去创建同一个名字的锁节点,这些锁节点的序列号不一样,我们可以将序列号最小的节点视为获取到锁,这样每个请求线程一次执行就能获取到锁对象
zookeeper.create()
方法创建节点成功会返回节点的完整路径,通过该路径我们可以分离出序列号,为了方便分割锁的名称和序列号,在锁名称后面加一个标识符-
,而且还可以整体作为前缀准确区分一各分布式锁【比如使用StringUtils.startWith()
,前缀加一个标识符能标记锁名称的开始和结束,这样不会方便同一个分布式锁的区分】,想办法通过序列号获取前置节点,如果前置节点为null,说明是第一个节点就直接去执行业务方法;如果前置节点不为null,说明当前线程应该被阻塞监听前置节点删除对应节点并释放锁后执行业务代码并删除锁释放前置节点
其他的节点对应的请求线程去监听序列号从小到大排序比自己小的在自己前面节点的删除事件,通过这种监听前一个节点的事件和回调来实现请求线程未获取锁阻塞的效果;
❓:这里存在一个问题,一个服务宕机了会自己删节点,后继节点会被唤醒,但是此时后继节点不知道还有没有前驱节点,可能发生宕机导致锁不住的情况出现线程安全问题
这种方式因为Zookeeper的特性不仅实现了阻塞锁还实现了公平锁,先创建节点的线程序列号更小会先获取锁
获取前驱节点的逻辑:
1️⃣:获取根节点下的所有子节点,一般能获取成功,获取失败都属于是不正常的情况,可以尝试抛一个获取锁常用的IllegalMonitorStateException
,如果子节点集合为空集合也说明有问题也抛该异常,因为获取前驱节点就是为了给刚创建的节点添加监听事件,所以子节点集合不应该为空;
2️⃣:注意获取到的子节点包含所有商品库存的锁,不同的商品应该可以并发,注意序列号不管是不是同一个商品的锁整体每个序列号都是唯一的,所以前驱节点不能简单地只根据序列号进行判断,要根据场景进行设计,在所有分布式锁都在一个父节点下,就要根据锁的名称来对分布式锁分类,只对同一类的分布式锁序列号进行排序找到前驱节点,获取到同一类锁的集合并对该集合判空,因为所有分布式锁的集合不为空但是一个分布式锁下的节点集合可能为空,其实也不可能,因为刚刚创建了一个该分布式锁下的节点还没删,为空肯定是出问题了,这种情况也直接抛异常
这个设计属实有点简陋,考虑使用单调栈或者数据结构优化一下才行,不然每个请求都去获取一遍所有子节点又对锁分类排序,性能不可能好
3️⃣:如果集合不为空判断当前节点在集合中的下标位置,如果下标位置小于0直接抛异常,下标位置大于0则返回前驱节点,下标位置为0则说明没有前驱节点,直接返回null
注意这里获取前置节点的代码是没有获取锁时的操作,此时还在获取前驱节点,前驱节点可能已经将锁释放掉删除节点了,此时再监听前驱节点的删除事件已经没有意义了,如果将该节点设置为前驱节点会导致当前节点永远不会被唤醒,因此,在设置监听前驱节点事件前还需要检查前驱节点是否还存在
❓:但是感觉还是有问题,因为没有获取到锁,再检查到执行监听事件期间前驱节点还是可能释放锁,导致删除事件永远不会触发
🔑:因为ls指令即Exist方法在判断节点是否存在的同时会给对应节点添加监听事件,暂时认为判断节点存在和添加监听事件这一条ls指令是原子性的,认为Zookeeper内部对节点的操作是上了锁的【Redis是通过单线程来保证指一条指令的原子性,但是Zookeeper不清楚是否保证了单条指令执行的原子性或者对节点的更新和监听操作是否上锁】,否则这里肯定有问题,老师并没有做解释
4️⃣:如果前驱节点存在,检查前驱节点是否仍存在Zookeeper
服务器中,如果仍然存在则监听前驱节点的删除节点事件,如果Zookeeper
服务器中前驱节点已经不存在了或者List
集合中没有前驱节点tryLock
方法直接返回true
表示上锁成功,直接执行业务方法即可
5️⃣:如果成功设置对前驱节点的删除事件监听,当回调方法调用时直接结束方法执行返回true表示当前线程成功获取锁,使用闭锁CountdownLatch
来阻塞当前线程,在回调方法中让锁计数减1来唤醒当前线程,这意味着回调线程和调用监听节点事件的线程不是同一个线程
代码示例
【分布式锁工厂类】
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 分布式锁工厂类
* 获取基于Redis的分布式锁 DistributedRedisLock、基于Zookeeper的分布式锁DistributedZookeeperLock
* @创建日期 2024/08/20
* @since 1.0.0
*/
public class DistributedLockClient {
private ZooKeeper zooKeeper;
private String uuid;
private StringRedisTemplate redisTemplate;
public void init(){
CountDownLatch countDownLatch = new CountDownLatch(1);
try {
zooKeeper = new ZooKeeper("192.168.200.132:2181", 30000, new Watcher() {
public void process(WatchedEvent event) {
Event.KeeperState eventState = event.getState();
if (Event.KeeperState.SyncConnected.equals(eventState) && Event.EventType.None.equals(event.getType())) {
//获取连接回调逻辑,通常让闭锁计数减1来放行主业务执行或者其他逻辑
countDownLatch.countDown();
} else if (Event.KeeperState.Closed.equals(eventState)) {
//关闭连接后的回调业务逻辑
}
}
});
} catch (IOException e) {
e.printStackTrace();
}
}
public void destroy(){
try {
if (zooKeeper != null){
zooKeeper.close();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public DistributedLockClient() {
this.uuid = UUID.randomUUID().toString();
}
public DistributedRedisLock getRedisLock(String lockName){
return new DistributedRedisLock(redisTemplate, lockName, uuid);
}
public DistributedZookeeperLock getZookeeperLock(String lockName){
return new DistributedZookeeperLock(zooKeeper,lockName);
}
}
【基于Zookeeper实现的分布式锁】
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 基于Zookeeper和Zookeeper的官方客户端实现的分布式锁
* @创建日期 2024/08/28
* @since 1.0.0
*/
public class DistributedZookeeperLock implements Lock {
private ZooKeeper zooKeeper;
/**
*锁的相关节点不要直接创建到根节点`/`下,全部创建到`/locks`节点下,即锁的路径为`/locks/lockName`
*/
private String lockName;
/**
*指定所有锁的父节点
*/
private static final String LOCKS_ROOT_PATH="/locks";
private String currentNodePath;
/**
* @param zooKeeper
* @param lockName
* @描述 初始化锁对象只是检查有没有对应的/locks节点,通过锁对象的lock方法来竞争创建对应节点获取对应的锁
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
*/
public DistributedZookeeperLock(ZooKeeper zooKeeper, String lockName) {
this.zooKeeper=zooKeeper;
this.lockName=lockName;
//每次获取锁都去检查是否有锁的根节点,没有根节点就创建锁的根节点"/locks",因为在没有父节点`/locks`的前提下创建子节点`/locks/lockName`会直接失败
try {
if (zooKeeper.exists(LOCKS_ROOT_PATH,false)==null) {
zooKeeper.create(LOCKS_ROOT_PATH,null,ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.PERSISTENT);
}
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* @描述 加锁通过调用tryLock方法实现
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
*/
public void lock() {
tryLock();
}
public void lockInterruptibly() throws InterruptedException {
}
/**
* @return boolean
* @描述 加锁方法尝试创建`\locks\lockName`节点,创建节点失败就等待重试创建节点的过程
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
*/
public boolean tryLock() {
try {
//创建序列化临时节点,返回该节点的路径
currentNodePath = zooKeeper.create(LOCKS_ROOT_PATH + "/" + lockName+"-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
//获取前置节点,如果前置节点为空,直接获取锁。否则监听前驱节点的删除事件
String preNodePath=getPreNodePath(currentNodePath);
//如果前驱节点为null,说明当前节点上锁成功,返回true
if (preNodePath != null) {
//有前驱节点才设置闭锁
CountDownLatch countDownLatch = new CountDownLatch(1);
//从Zookeeper服务器中获取节点列表到选出前驱节点期间前驱节点可能已经释放掉了,因此需要再次检查前驱节点是否仍然存在,
// 并在检查前驱节点存在的同时开启对前驱节点的删除事件监听,认为检查和开启事件监听在Zookeeper中是原子性的,否则这里肯定有问题
if (zooKeeper.exists(LOCKS_ROOT_PATH + "/" + preNodePath, new Watcher() {
public void process(WatchedEvent event) {
//如果前驱节点存在通过闭锁阻塞当前线程,删除前驱节点事件发生触发该回调让闭锁计数减1,同时意味着回调函数执行线程和调用事件监听方法的线程不是同一个线程
countDownLatch.countDown();
}
}) == null) {
//如果前驱节点已经不存在,说明锁已经被释放,当前线程获取锁成功,后面的线程会加到该节点后面,因为该节点删除事件不会触发,后续线程也拿不到锁
//只可能当前节点删除后续节点才能获取锁,因此这里不需要担心同一时刻其他线程的竞争问题,序列化临时节点的序列号已经解决了这个问题
return true;
}
//没有监听到前驱节点的删除事件当前线程进入阻塞
countDownLatch.await();
}
return true;
} catch (Exception e) {
e.printStackTrace();
}
//执行到这儿说明创建序列化临时节点就发生了异常,上锁直接失败
return false;
}
/**
* @return {@link String }
* @描述 获取前驱节点的路径
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
* @param currentNodePath
*/
private String getPreNodePath(String currentNodePath) {
try {
//获取分布式锁节点下的所有子节点,注意返回的是子节点的名称不包含父节点的路径
List<String> children = zooKeeper.getChildren(LOCKS_ROOT_PATH, false);
if (CollectionUtils.isEmpty(children)) {
throw new IllegalMonitorStateException("Zookeeper分布式锁列表数量为0");
}
//获取当前锁下的节点列表
List<String> nodesCurrentLock = children.stream().filter(node -> StringUtils.startsWith(node, lockName + "-")).collect(Collectors.toList());
//节点列表判空,正常情况下因为刚创建一个节点还没有删除,节点列表是不可能为空的,除非Zookeeper服务器宕机
if (CollectionUtils.isEmpty(nodesCurrentLock)) {
throw new IllegalMonitorStateException("当前Zookeeper分布式锁下的阻塞列表数量为0");
}
//将阻塞列表中的节点按序列号的自然顺序排序,因为路径部分相同,实际还是比较的序列号部分按升序排序
Collections.sort(nodesCurrentLock);
//获取当前节点在阻塞列表中的下标索引
String currentNode = StringUtils.substringAfterLast(currentNodePath, "/");
int index = Collections.binarySearch(nodesCurrentLock, currentNode);
//如果下标小于0直接抛异常,这种情况一般不会发生
if (index<0){
throw new IllegalMonitorStateException("当前Zookeeper分布式锁下的阻塞列表数组下标越界");
}else if(index>0){
//说明有前驱节点,返回前驱节点
return nodesCurrentLock.get(index-1);
}
//没有前驱节点就返回null,说明当前节点就是首个节点
return null;
} catch (Exception e) {
e.printStackTrace();
throw new IllegalMonitorStateException("查询所有Zookeeper分布式锁列表失败");
}
}
public boolean tryLock(long time, TimeUnit unit) {
return false;
}
/**
* @描述 尝试删除`\locks\lockName`节点
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
*/
public void unlock() {
try {
zooKeeper.delete(currentNodePath,-1);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (KeeperException e) {
e.printStackTrace();
}
}
public Condition newCondition() {
return null;
}
}
【业务代码】
xxxxxxxxxx
public String deduct() {
DistributedZookeeperLock stockLock = distributedLockClient.getZookeeperLock("stockLock");
stockLock.lock();
try {
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
} finally {
stockLock.unlock();
}
return "库存扣减成功!";
}
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,使用上诉基于Zookeeper临时序列化节点和事件监听实现的阻塞式不可重入分布式锁对库存数量5000进行单次扣减1,累计5000次扣减请求
测试结果:
1️⃣:吞吐量559,5000次扣减操作全部成功,Redis中库存数据应从5000减至0,实际是0,没有出现线程安全问题
基于Zookeeper实现的分布式锁实现可重入有两种方式
第一种是在节点中记录持有锁的具体线程、重入次数
第二种是借助ThreadLocal
,通过ThreadLocal<Integer>
保存当前线程的锁重入计数
这里目前只实现了第二种方式
原理
从ThreadLocal<Integer>
中获取锁重入计数,如果获取的计数为null或者小于等于0,说明当前线程还没有上锁,执行竞争锁逻辑;如果获取的计数大于0说明当前线程已经获取锁,在该计数上加1表示锁重入次数加1;首次获取锁时将锁重入计数置为1;
解锁时先将锁重入计数-1,因为只有获取锁的线程才可能执行解锁方法,此时锁重入计数必然大于等于1;判断减1后的锁重入计数是否为0,如果为0彻底释放锁,如果不为0说明锁还没释放干净,直接返回不能删除锁
代码示例
【分布式锁工厂类】
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 分布式锁工厂类
* 获取基于Redis的分布式锁 DistributedRedisLock、基于Zookeeper的分布式锁DistributedZookeeperLock
* @创建日期 2024/08/20
* @since 1.0.0
*/
public class DistributedLockClient {
private ZooKeeper zooKeeper;
private String uuid;
private StringRedisTemplate redisTemplate;
public void init(){
CountDownLatch countDownLatch = new CountDownLatch(1);
try {
zooKeeper = new ZooKeeper("192.168.200.132:2181", 30000, new Watcher() {
public void process(WatchedEvent event) {
Event.KeeperState eventState = event.getState();
if (Event.KeeperState.SyncConnected.equals(eventState) && Event.EventType.None.equals(event.getType())) {
//获取连接回调逻辑,通常让闭锁计数减1来放行主业务执行或者其他逻辑
countDownLatch.countDown();
} else if (Event.KeeperState.Closed.equals(eventState)) {
//关闭连接后的回调业务逻辑
}
}
});
} catch (IOException e) {
e.printStackTrace();
}
}
public void destroy(){
try {
if (zooKeeper != null){
zooKeeper.close();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public DistributedLockClient() {
this.uuid = UUID.randomUUID().toString();
}
public DistributedRedisLock getRedisLock(String lockName){
return new DistributedRedisLock(redisTemplate, lockName, uuid);
}
public DistributedZookeeperLock getZookeeperLock(String lockName){
return new DistributedZookeeperLock(zooKeeper,lockName);
}
}
【基于Zookeeper实现的分布式锁】
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 基于Zookeeper和Zookeeper的官方客户端实现的分布式锁
* @创建日期 2024/08/28
* @since 1.0.0
*/
public class DistributedZookeeperLock implements Lock {
private ZooKeeper zooKeeper;
/**
*锁的相关节点不要直接创建到根节点`/`下,全部创建到`/locks`节点下,即锁的路径为`/locks/lockName`
*/
private String lockName;
/**
*指定所有锁的父节点
*/
private static final String LOCKS_ROOT_PATH="/locks";
/**
* 当前节点的全路径
*/
private String currentNodePath;
/**
*使用ThreadLocal记录锁的重入次数
*/
private static final ThreadLocal<Integer> LOCK_COUNT=new ThreadLocal<>();
/**
* @param zooKeeper
* @param lockName
* @描述 初始化锁对象只是检查有没有对应的/locks节点,通过锁对象的lock方法来竞争创建对应节点获取对应的锁
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
*/
public DistributedZookeeperLock(ZooKeeper zooKeeper, String lockName) {
this.zooKeeper=zooKeeper;
this.lockName=lockName;
//每次获取锁都去检查是否有锁的根节点,没有根节点就创建锁的根节点"/locks",因为在没有父节点`/locks`的前提下创建子节点`/locks/lockName`会直接失败
try {
if (zooKeeper.exists(LOCKS_ROOT_PATH,false)==null) {
zooKeeper.create(LOCKS_ROOT_PATH,null,ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.PERSISTENT);
}
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* @描述 加锁通过调用tryLock方法实现
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
*/
public void lock() {
tryLock();
}
public void lockInterruptibly() throws InterruptedException {
}
/**
* @return boolean
* @描述 加锁方法尝试创建`\locks\lockName`节点,创建节点失败就等待重试创建节点的过程
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
*/
public boolean tryLock() {
try {
//锁重入判断,如果当前线程的锁重入计数大于1,认为当前线程占有锁,当前线程因为锁重入让锁重入计数加1并直接返回true表示上锁成功
//注意啊,锁重入计数的初始化首次上锁需要将锁重入计数置为1
Integer curLockCount = LOCK_COUNT.get();
if (curLockCount != null && curLockCount >0) {
LOCK_COUNT.set(curLockCount +1);
return true;
}
//创建序列化临时节点,返回该节点的路径
currentNodePath = zooKeeper.create(LOCKS_ROOT_PATH + "/" + lockName+"-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
//获取前置节点,如果前置节点为空,直接获取锁。否则监听前驱节点的删除事件
String preNodePath=getPreNodePath(currentNodePath);
//如果前驱节点为null,说明当前节点上锁成功,返回true
if (preNodePath != null) {
//有前驱节点才设置闭锁
CountDownLatch countDownLatch = new CountDownLatch(1);
//从Zookeeper服务器中获取节点列表到选出前驱节点期间前驱节点可能已经释放掉了,因此需要再次检查前驱节点是否仍然存在,
// 并在检查前驱节点存在的同时开启对前驱节点的删除事件监听,认为检查和开启事件监听在Zookeeper中是原子性的,否则这里肯定有问题
if (zooKeeper.exists(LOCKS_ROOT_PATH + "/" + preNodePath, event -> {
//如果前驱节点存在通过闭锁阻塞当前线程,删除前驱节点事件发生触发该回调让闭锁计数减1,同时意味着回调函数执行线程和调用事件监听方法的线程不是同一个线程
countDownLatch.countDown();
}) == null) {
//如果前驱节点已经不存在,说明锁已经被释放,当前线程获取锁成功,后面的线程会加到该节点后面,因为该节点删除事件不会触发,后续线程也拿不到锁
//只可能当前节点删除后续节点才能获取锁,因此这里不需要担心同一时刻其他线程的竞争问题,序列化临时节点的序列号已经解决了这个问题
LOCK_COUNT.set(1);
return true;
}
//没有监听到前驱节点的删除事件当前线程进入阻塞,注意这里有前驱节点且前驱节点仍存在于Zookeeper服务器中才会让当前线程进入阻塞
countDownLatch.await();
}
LOCK_COUNT.set(1);
return true;
} catch (Exception e) {
e.printStackTrace();
}
//执行到这儿说明创建序列化临时节点就发生了异常,上锁直接失败
return false;
}
/**
* @return {@link String }
* @描述 获取前驱节点的路径
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
* @param currentNodePath
*/
private String getPreNodePath(String currentNodePath) {
try {
//获取分布式锁节点下的所有子节点,注意返回的是子节点的名称不包含父节点的路径
List<String> children = zooKeeper.getChildren(LOCKS_ROOT_PATH, false);
if (CollectionUtils.isEmpty(children)) {
throw new IllegalMonitorStateException("Zookeeper分布式锁列表数量为0");
}
//获取当前锁下的节点列表
List<String> nodesCurrentLock = children.stream().filter(node -> StringUtils.startsWith(node, lockName + "-")).collect(Collectors.toList());
//节点列表判空,正常情况下因为刚创建一个节点还没有删除,节点列表是不可能为空的,除非Zookeeper服务器宕机
if (CollectionUtils.isEmpty(nodesCurrentLock)) {
throw new IllegalMonitorStateException("当前Zookeeper分布式锁下的阻塞列表数量为0");
}
//将阻塞列表中的节点按序列号的自然顺序排序,因为路径部分相同,实际还是比较的序列号部分按升序排序
Collections.sort(nodesCurrentLock);
//获取当前节点在阻塞列表中的下标索引
String currentNode = StringUtils.substringAfterLast(currentNodePath, "/");
int index = Collections.binarySearch(nodesCurrentLock, currentNode);
//如果下标小于0直接抛异常,这种情况一般不会发生
if (index<0){
throw new IllegalMonitorStateException("当前Zookeeper分布式锁下的阻塞列表数组下标越界");
}else if(index>0){
//说明有前驱节点,返回前驱节点
return nodesCurrentLock.get(index-1);
}
//没有前驱节点就返回null,说明当前节点就是首个节点
return null;
} catch (Exception e) {
e.printStackTrace();
throw new IllegalMonitorStateException("查询所有Zookeeper分布式锁列表失败");
}
}
public boolean tryLock(long time, TimeUnit unit) {
return false;
}
/**
* @描述 尝试删除`\locks\lockName`节点
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/29
* @since 1.0.0
*/
public void unlock() {
try {
LOCK_COUNT.set(LOCK_COUNT.get()-1);
if(LOCK_COUNT.get() == 0){
//如果锁计数减为0就删除对应的节点
zooKeeper.delete(currentNodePath,-1);
}
} catch (InterruptedException e) {
e.printStackTrace();
} catch (KeeperException e) {
e.printStackTrace();
}
}
public Condition newCondition() {
return null;
}
}
【业务代码】
xxxxxxxxxx
/**
* @描述 减库存
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/12
* @since 1.0.0
*/
public String deduct() {
DistributedZookeeperLock stockLock = distributedLockClient.getZookeeperLock("stockLock");
stockLock.lock();
try {
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
testReentrant();
} finally {
stockLock.unlock();
}
return "库存扣减成功!";
}
public void testReentrant(){
DistributedZookeeperLock stockLock = distributedLockClient.getZookeeperLock("stockLock");
stockLock.lock();
try {
System.out.println("锁发生重入");
}finally {
stockLock.unlock();
}
}
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,使用上诉基于Zookeeper临时序列化节点和事件监听实现的阻塞式可重入分布式锁对库存数量5000进行单次扣减1,累计5000次扣减请求
测试结果:
1️⃣:吞吐量612,5000次扣减操作全部成功,Redis中库存数据应从5000减至0,实际是0,没有出现线程安全问题
Zookeeper分布式锁的特点
ZNode临时节点不可重复,通过都去自旋重试创建同一个ZNode节点实现独占排他
通过序列化临时节点和监听前驱节点的删除事件可以实现竞争锁的线程阻塞等待,新创建节点根据序列号检查是否有前驱节点,如果没有直接获取锁,如果有且设置监听事件时仍存在则当前线程使用闭锁阻塞,在前驱节点的删除事件回调中让闭锁计数减至0来放行当前线程;如果前驱节点在设置监听事件时已经不存在了也说明当前线程可以直接获取锁
通过序列化临时节点的阻塞队列还实现了公平锁,先创建节点的线程先获取到锁
由于Zookeeper客户端断开连接对应客户端的临时节点全部删除的特性,也天然促成分布式锁不会因为服务器宕机导致的死锁问题,从而无需考虑锁有效期、续期、和正确释放当前线程的锁的问题
通过ThreadLocal对当前线程的锁计数进行记录,加锁是如果锁重入计数大于0,说明当前线程已经获取到锁,当前加锁为锁重入,直接累加锁重入计数即可,如果锁重入计数为null或者0,说明当前线程还没有获取到锁,当前线程进入竞争锁的逻辑;释放锁时对所重入计数减1,比较减1后的锁重入计数是否为0,为0说明锁释放干净了,可以删除对应节点释放锁;如果减1后的锁重入计数大于0,说明锁还没有释放干净,直接返回等待后续锁释放干净才删除对应节点;锁重入实现也能防止因为锁不可重入当锁重入发生时带来的死锁问题;此外可重入还可以借助Zookeeper节点的数据内容来作为锁重入计数来实现
和基于Redis实现的可重入分布式锁的对比
对于防客户端宕机导致的死锁现象,基于Redis实现通过给锁设置有效时间来解决,并需要解决由此引发的锁提前过期和其他线程错误获取锁以及错误释放锁的问题;基于Zookeeper实现通过临时节点当客户端宕机与Zookeeper服务器断开连接,Zookeeper服务器心跳检测不到客户端程序就会直接删除对应客户端创建的临时节点来删除锁
对于可重入实现,基于Redis实现通过Redis中的Hash数据模型、以UUID作为服务器标识,用线程id作为线程标识,value作为锁重入计数来实现的;基于Zookeeper实现通过ThreadLocal存储锁重入计数或者对应节点的数据内容来实现,也可以通过ConcurrentHashMap来实现,比如Zookeeper客户端Curator就是基于ConcurrentHashMap来实现的
对于防误删,基于Redis实现通过UUID标识服务实例,用线程id标识请求线程,删除前检查当前的锁是否是当前线程获取到的锁,并使用Lua脚本来保证该过程的多步操作原子性来实现防误删;基于Zookeeper实现通过为每个请求线程创建唯一的临时序列化节点,释放锁也只删除对应当前线程的临时节点,不会影响到其他线程创建的临时节点,通过该方式实现锁的防误删
对于原子性操作,基于Redis实现通过Lua脚本的一次提交执行多条Redis指令来保证原子性;基于Zookeeper实现通过Zookeeper的单条指令如创建节点、删除节点、查询并监听节点都是原子性的【因为没学过Zookeeper,对这里不是很理解是如何保障原子性的】,对于查找前驱节点和监听前驱节点两步操作直接无法保证原子性,通过使用以找到的前驱节点验证该节点存在并设置监听该节点的删除事件验证监听两步操作的原子性来保证前驱节点如果在监听前已经被释放则不再执行监听直接获取锁,避免前驱节点删除事件永远不会被触发导致请求线程一直被阻塞
对于可重入,基于Redis实现通过UUID:线程ID作为Hash模式的Field,锁重入计数作为value,使用Lua脚本保证验证锁是当前线程的锁并对锁重入计数累加或者递减来实现的;基于Zookeeper实现有多种实现方式,这里使用的ThreadLocal保存当前线程的锁重入计数对锁重入计数增删实现的,还可以通过Zookeeper节点数据作为锁重入计数来实现,也可以参考Curator的基于ConcurrentHashMap来实现锁重入
基于Zookeeper实现通过临时节点天然防客户端宕机死锁,不需要考虑锁的有效期问题和自动续期问题;基于Redis实现通过给Hash数据模型设置有效时间通过定时任务Timer或者Netty的时间轮来实现给锁自动续期
对于锁载体单点故障,基于Redis实现使用集群形式来存放分布式锁可能由于IO开销导致的延迟在主机宕机时因为数据未同步导致锁失效,而只使用一台Redis可以认为一定会出现单点故障问题,在集群环境下需要使用红锁来解决锁失效的问题;Zookeeper服务器天然就要搭建为集群方式使用,Zookeeper集群是强一致性集群,一致性保障比Redis集群好很多,也不存在主节点宕机导致数据未同步丢失的情况
基于Zookeeper实现的阻塞锁非常地方便,使用节点事件监听就能完成,但是基于Redis实现阻塞锁就非常地麻烦,公平锁方面Zookeeper通过序列号和阻塞队列相较于Redis也非常容易实现
🔎:弹幕指出:说一下zk的问题,如果单个线程oom了,锁不释放,相当于死锁.。可重入锁都没有兜底threadlocal的clean,易引发内存泄露。
Curator是Netflix贡献给Apache的,目前属于Apache的顶级项目,Curator针对Zookeeper提供了很多高级工具的封装,比如分布式锁、还解决了Zookeeper官方客户端诸如失败重连、多次反复事件监听很多缺陷
Curator主要解决了Zookeeper官方客户端的三类问题
封装ZooKeeper client
与ZooKeeper server
之间的连接处理
提供了一套Fluent风格的操作API
提供基于ZooKeeper
各种应用场景实现, 比如分布式锁服务、集群领导选举、共享计数器、缓存机制、分布式队列等抽象封装,这些实现都遵循Zookeeper
的最佳实践,并考虑了各种极端情况
Curator由核心框架curator-framework
和curator-recipes
两部分组成,curator-framework
主要对Zookeeper的底层做了许多封装,便于用户更方面操作Zookeeper;curator-recipes
对一些Zookeeper典型应用场景比如分布式锁做了封装
引入依赖
使用Curator需要分别引入curator-framework
和curator-recipes
,注意这两个依赖中都含有zookeeper
官方客户端依赖,使用zookeeper客户端需要与服务端的版本相同,实际上也没这么严格,我这里使用zookeeper3.5.7的服务器使用zookeeper3.7.0的客户端也没有出现任何问题,使用Curator并不需要使用zookeeper客户端,因此为了避免版本冲突问题,最好将zookeeper客户端从curator-framework
和curator-recipes
中排除出去,有需要的时候再单独进行引入
xxxxxxxxxx
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>4.3.0</version>
<exclusions>
<exclusion>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.3.0</version>
<exclusions>
<exclusion>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.4.14</version>
</dependency>
编写配置类初始化curator-framework
的客户端CuratorFrameWork
,该客户端对象类似于Redis
客户端中的RedisTemplate
,Redisson
中的RedissonClient
配置类
CuratorFramework
是一个接口,该接口有一个子接口WatcherRemoveCuratorFramework
和一个子实现类CuratorFrameworkImpl
,子实现类CuratorFrameworkImpl
有两个子类NamespaceFacade
和WatcherRemovalFacade
,一般常用工厂类的newClient
方法来初始化CuratorFramework
组件,该方法有两个重载方法CuratorFramework. newClient()
Zookeeper官方客户端是不具备连接重试功能的,这就是Curator对Zookeeper官方客户端做出的优化之一
其中newClient(String connectString, RetryPolicy retryPolicy)
方法不需要指定会话和连接超时时间
第一个参数是指定Zookeeper服务器地址,参数格式为192.168.200.132:2181
,注释说这是一个服务器地址列表,虽然注释没指明具体格式,猜测使用逗号分隔多个服务器地址
第二个参数retryPolicy
是指定重试策略,RetryPolicy
是一个接口,有两个直接子类分别是SleepingRetry
和RetryForever
,前者表示有间歇的重试,后者表示持续重试,持续重试可能导致服务器浪费大量的资源,一般不推荐使用该策略;可以使用SleepingRetry
的子类RetryNTime
,该策略可以指定每隔多少时间重试一次,最多重试多少次;一般使用SleepingRetry
的子类ExponentialBackoffRetry
指数补偿重试,除了可以指定重试次数,还可以指定一个初始间隔时间,第一次在初始间隔时间重试,以后每次重试的间隔时间会递增,重试次数越多间隔时间越长,这样的策略设计更符合节省服务器资源的标准
第二个重载方法CuratorFramework newClient(String connectString, int sessionTimeoutMs, int connectionTimeoutMs, RetryPolicy retryPolicy)
需要额外指定会话和连接超时时间
初始化CuratorFramework
对象以后需要使用start方法手动启动一下,否则Curator底层很多方法或者功能都是不工作的,即使调用了也无法使用,Curator的大多功能都通过该对象进行调用
xxxxxxxxxx
/**
* @author Earl
* @version 1.0.0
* @描述 CuratorFramework的配置类
* @创建日期 2024/08/30
* @since 1.0.0
*/
public class CuratorConfig {
public CuratorFramework curatorFramework(){
//初始化一个重试策略,这里使用的是指数补偿策略
//初始重试间隔10s钟,最大重试次数3次,这种参数声明更喜欢使用多态的方式来进行声明
RetryPolicy retryPolicy = new ExponentialBackoffRetry(10000, 3);
CuratorFramework curatorFrameworkClient = CuratorFrameworkFactory.newClient("192.168.200.132", retryPolicy);
//手动启动curatorFrameworkClient
curatorFrameworkClient.start();
return curatorFrameworkClient;
}
}
InterProcessMutex
是类似于ReentrantLock
的Curator提供的基于Zookeeper实现的分布式可重入锁
概述
InterProcessMutex
是一个普通类,可以直接通过构造方法初始化实例对象
该构造方法需要我们上面初始化好的CuratorFramework
客户端作为第一个参数,第二个参数需要分布式锁的用户自定义锁的路径
注意InterProcessMutex
是根据锁对象来区分是否同一把锁的,也就是不能通过向构造方法传参相同的path
来获取同一把锁,如果使用相同的路径来调用构造方法创建不同的InterProcessMutex
对象,会直接在Zookeeper中覆盖前一把锁的节点,导致锁无法被释放,程序一直卡住
此时锁重入的锁对象就需要通过调用方法的参数来传递,这种方式极其不方便,因为这意味着定义方法的时候就要考虑锁重入的问题,还要考虑是哪一把锁重入的问题,非常地不灵活
xxxxxxxxxx
/**
* @param client client
* @param path the path to lock
*/
public InterProcessMutex(CuratorFramework client, String path)
{
this(client, path, new StandardLockInternalsDriver());
}
/**
* @param client client
* @param path the path to lock
* @param driver lock driver
*/
public InterProcessMutex(CuratorFramework client, String path, LockInternalsDriver driver)
{
this(client, path, LOCK_NAME, 1, driver);
}
InterProcessMutex
的用法示例
void ---> acquire() throws Exception
是InterProcessMutex
的加锁方法,void release() throws Exception
是InterProcessMutex
的解锁方法
xxxxxxxxxx
public class StockServiceImpl implements StockService {
/**
* 商品库存必须是单例的,service是单例的,其中的成员变量就一定是单例的
*/
private Stock stock = new Stock();
private StringRedisTemplate redisTemplate;
private CuratorFramework curatorFrameworkClient;
/**
* @描述 减库存
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/12
* @since 1.0.0
*/
public String deduct() {
InterProcessMutex mutex = new InterProcessMutex(curatorFrameworkClient, "/curator/locks");
try {
//加锁
mutex.acquire();
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
//解锁
mutex.release();
} catch (Exception e) {
e.printStackTrace();
}
}
return "库存扣减成功!";
}
}
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,使用InterProcessMutex
对库存数量5000进行单次扣减1,累计5000次扣减请求
测试结果:
1️⃣:吞吐量458,5000次扣减操作全部成功,Redis中库存数据应从5000减至0,实际是0,没有出现线程安全问题
结果分析:
1️⃣:很菜,还没有我们自己实现的性能高
锁重入的使用方式
错误示范
这种方式来进行锁重入会导致程序运行卡死,因为InterProcessMutex
只能通过对象来识别是否同一把锁,通过相同path
来创建的两个InterProcessMutex
实际上是两把不同的锁,但是因为路径相同,会直接覆盖掉Zookeeper服务器中先上锁创建的节点,导致第一把锁无法释放并导致程序卡死
xxxxxxxxxx
public class StockServiceImpl implements StockService {
/**
* 商品库存必须是单例的,service是单例的,其中的成员变量就一定是单例的
*/
private Stock stock = new Stock();
private StringRedisTemplate redisTemplate;
private CuratorFramework curatorFrameworkClient;
/**
* @描述 减库存
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/12
* @since 1.0.0
*/
public String deduct() {
InterProcessMutex mutex = new InterProcessMutex(curatorFrameworkClient, "/curator/locks");
try {
mutex.acquire();
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
testReentrant();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
mutex.release();
} catch (Exception e) {
e.printStackTrace();
}
}
return "库存扣减成功!";
}
public void testReentrant() throws Exception {
InterProcessMutex mutex = new InterProcessMutex(curatorFrameworkClient, "/curator/locks");
mutex.acquire();
try {
System.out.println("锁发生重入");
}finally {
mutex.release();
}
}
}
正确示范
此时锁重入的锁对象就需要通过调用方法的参数来传递,这种方式极其不方便,因为这意味着定义方法的时候就要考虑锁重入传递锁对象的问题,还要考虑方法是不是自己上全新的锁,非常地不灵活;或者通过成员变量传参一个锁对象,总之非常不好
xxxxxxxxxx
public class StockServiceImpl implements StockService {
/**
* 商品库存必须是单例的,service是单例的,其中的成员变量就一定是单例的
*/
private Stock stock = new Stock();
private StringRedisTemplate redisTemplate;
private CuratorFramework curatorFrameworkClient;
/**
* @描述 减库存
* @author Earl
* @version 1.0.0
* @创建日期 2024/08/12
* @since 1.0.0
*/
public String deduct() {
InterProcessMutex mutex = new InterProcessMutex(curatorFrameworkClient, "/curator/locks");
try {
mutex.acquire();
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
//发生锁重入还要将锁对象传递给发生锁重入的方法,非常地蠢
testReentrant(mutex);
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
mutex.release();
} catch (Exception e) {
e.printStackTrace();
}
}
return "库存扣减成功!";
}
public void testReentrant(InterProcessMutex mutex) throws Exception {
mutex.acquire();
try {
System.out.println("锁发生重入");
}finally {
mutex.release();
}
}
}
源码解析
锁的初始化
核心:构造了一个InterProcessMutex
对象并将用户自定义根节点路径存入其中的basePath
属性,校验用户自定义的父节点路径,构造一个LockInternals
对象将标准锁内部驱动赋值给driver
属性、通过用户自定义的curatorFramework
构造可以监听的curatorFramework赋值给client
属性、将用户自定义父节点路径赋值给basePath
属性、将写死的lock-
赋值给lockName
属性、将最大租约数1赋值给maxLeases
属性、将basePath+"/"+lockName
赋值给path
属性,将LockInternals
对象赋值给InterProcessMutex
对象的internals
属性,后续主要通过internals
属性的LockInternals
对象来对锁进行操作
xxxxxxxxxx
1️⃣ InterProcessMutex构造
/**
* @param client client
* @param path the path to lock
*/
public InterProcessMutex(CuratorFramework client, String path)
{
//StandardLockInternalsDriver的意思是标准的锁内部驱动
this(client, path, new StandardLockInternalsDriver());
}
2️⃣ InterProcessMutex构造
/**
* @param client client
* @param path the path to lock
* @param driver lock driver
*/
public InterProcessMutex(CuratorFramework client, String path, LockInternalsDriver driver)
{
//这里的LOCK_NAME就是一个常量`lock-`,因此path只是创建的锁的父节点路径,实际的路径是`path\lock-序列号`,创建的也是临时序列化节点
//第三个参数是maxLeases,没听清老师说啥名字,隐约听着叫什么最大租约,意思是最大可以获取该分布式锁的跨JVM线程数量,即默认是互斥锁,经过多方确认maxLeases确实是叫做最大租约数
this(client, path, LOCK_NAME, 1, driver);
}
3️⃣ InterProcessMutex构造
InterProcessMutex(CuratorFramework client, String path, String lockName, int maxLeases, LockInternalsDriver driver)
{
basePath = PathUtils.validatePath(path);3️⃣-1️⃣ //构造方法中首先校验路径,验证通过返回的是path本身
internals = new LockInternals(client, driver, path, lockName, maxLeases);3️⃣-2️⃣ //根据传入参数创建LockInternal对象,后续加锁解锁都是通过该internals对象来处理的
}
3️⃣-1️⃣ PathUtils.validatePath(path)
/**
* Validate the provided znode path string
* @param path znode path string
* @return The given path if it was valid, for fluent chaining
* @throws IllegalArgumentException if the path is invalid
*/
public static String validatePath(String path) throws IllegalArgumentException {
//判断路径是否为空
if (path == null) {
throw new IllegalArgumentException("Path cannot be null");
}
//路径长度是否为0
if (path.length() == 0) {
throw new IllegalArgumentException("Path length must be > 0");
}
//判断路径第一位是不是`/`,路径第一位不是`/`会抛出异常
if (path.charAt(0) != '/') {
throw new IllegalArgumentException(
"Path must start with / character");
}
//判断路径长度是否为1,如果是路径第一位是`/`且长度为1即路径刚好是`/`此时就可以直接返回了
if (path.length() == 1) { // done checking - it's the root
return path;
}
//如果路径不是`/`就判断路径的最后一位是否是/,路径的最后一位不能是/,否则会直接抛异常
if (path.charAt(path.length() - 1) == '/') {
throw new IllegalArgumentException(
"Path must not end with / character");
}
//以下是其余验证,比如验证是否有相对路径等东西,没讲后面自己补
String reason = null;
char lastc = '/';
char chars[] = path.toCharArray();
char c;
for (int i = 1; i < chars.length; lastc = chars[i], i++) {
c = chars[i];
if (c == 0) {
reason = "null character not allowed @" + i;
break;
} else if (c == '/' && lastc == '/') {
reason = "empty node name specified @" + i;
break;
} else if (c == '.' && lastc == '.') {
if (chars[i-2] == '/' &&
((i + 1 == chars.length)
|| chars[i+1] == '/')) {
reason = "relative paths not allowed @" + i;
break;
}
} else if (c == '.') {
if (chars[i-1] == '/' &&
((i + 1 == chars.length)
|| chars[i+1] == '/')) {
reason = "relative paths not allowed @" + i;
break;
}
} else if (c > '\u0000' && c < '\u001f'
|| c > '\u007f' && c < '\u009F'
|| c > '\ud800' && c < '\uf8ff'
|| c > '\ufff0' && c < '\uffff') {
reason = "invalid charater @" + i;
break;
}
}
if (reason != null) {
throw new IllegalArgumentException(
"Invalid path string \"" + path + "\" caused by " + reason);
}
return path;
}
3️⃣-2️⃣ LockInternals构造
LockInternals(CuratorFramework client, LockInternalsDriver driver, String path, String lockName, int maxLeases)
{
this.driver = driver;
this.lockName = lockName;
this.maxLeases = maxLeases;//一定要记住这个值写死了就是1,后面有大作用
this.client = client.newWatcherRemoveCuratorFramework();//通过我们初始化的curatorFramework构造了一个可以监听的curatorFramework来赋值给client属性
this.basePath = PathUtils.validatePath(path);//再次重复3️⃣-1️⃣ 中的校验路径操作并且将path赋值给lockInternals对象中的basePath属性,重复没必要的验证,老师说写的一般但是是大公司的作品,不多评价
this.path = ZKPaths.makePath(path, lockName);3️⃣-2️⃣-1️⃣ //将`用户自定义路径`+`/lock-`来组成最终的节点路径并赋值给lockInternals对象中的path属性
}
3️⃣-2️⃣-1️⃣ ZKPaths.makePath(path, lockName)
/**
* Given a parent path and a child node, create a combined full path
*
* @param parent the parent
* @param child the child
* @return full path
*/
public static String makePath(String parent, String child)
{
// 2 is the maximum number of additional path separators inserted
int maxPathLength = nullableStringLength(parent) + nullableStringLength(child) + 2;//获取path和lockName的长度求和并加上2得到要拼接路径的长度,这里除了给`/`用的一个长度还多出来一个长度,debug数了一下确实多出来一位
// Avoid internal StringBuilder's buffer reallocation by specifying the max path length
StringBuilder path = new StringBuilder(maxPathLength);//使用拼接路径的长度实例化一个StringBuilder对象
joinPath(path, parent, child);//使用StringBuilder拼接path和lockName并转成字符串返回存入lockInternals对象中的path属性
return path.toString();
}
加锁方法
核心:这里面写的很绕但是核心逻辑还是和我们自己的实现差不多,使用ConcurrentHashMap
来存储锁重入计数信息,加锁先从ConcurrentHashMap
即threadData
通过当前线程获取锁重入计数对象LockData
,如果不为null
说明本次上锁是锁重入,直接原子整数累加1;如果LockData为null
就进入获取锁的阶段,会拼接出完整的节点对象路径并创建临时序列化节点,并获取子节点列表,判断创建的临时序列化节点的序列号在子节点列表中是否小于最大租约数,如果小于就获取锁成功,这里最大租约数默认写死就是1,所以只有最小的节点才能获取锁,返回获取锁的标志作为返回当前节点完整路径的标志并将路径了当前线程以及锁重入计数为1封装到LockData
中传递给threadData
并结束上锁方法;如果大于最大租约数就去监听当前节点下标-最大租约数的节点的删除事件,里面的事件监听并判断前驱节点是否仍然存在使用的getData
方法,比我们使用的exists
方法的优势在于exists
方法不论节点是否存在都会进行监听,但是getData
方法如果节点不存在就不会进行监听,这样可以避免客户端资源的泄露和浪费;面试按前面手动实现的过程结合这个流程来说即可
xxxxxxxxxx
private final ConcurrentMap<Thread, LockData> InterProcessMutex.threadData = Maps.newConcurrentMap();
private static class InterProcessMutex.LockData
{
final Thread owningThread;
final String lockPath;
final AtomicInteger lockCount = new AtomicInteger(1);
}
static final String CreateBuilderImpl.PROTECTED_PREFIX = "_c_";
-----------------------------------------------------------------------------------------------------
interProcessMutex.acquire()
/**
* Acquire the mutex - blocking until it's available. Note: the same thread
* can call acquire re-entrantly. Each call to acquire must be balanced by a call
* to {@link #release()}
*
* @throws Exception ZK errors, connection interruptions
*/
public void acquire() throws Exception
{
if ( !internalLock(-1, null) )1️⃣ //加锁方法实际上调用的是internalLock(-1, null)方法
{
throw new IOException("Lost connection while trying to acquire lock: " + basePath);
}
}
1️⃣ interProcessMutex.internalLock(-1, null)
private boolean internalLock(long time, TimeUnit unit) throws Exception
{
/*
Note on concurrency: a given lockData instance
can be only acted on by a single thread so locking isn't necessary
*/
Thread currentThread = Thread.currentThread();//获取当前线程
LockData lockData = threadData.get(currentThread);//通过属性threadData获取锁的重入信息LockData,threadData是InterProcessMutex类中的ConcurrentHashMap类型的属性,该Map保存了锁的重入信息,以当前线程作为Map的key,以LockData作为Map的value,LockData是InterProcessMutex中定义的一个静态内部类,其中有三个属性,Thread类型的属性owningThread表示当前LockData对象属于哪一个线程,String类型的属性lockPath记录当前线程创建的对应节点的路径,原子整数AtomicInteger类型的lockCount属性记录了当前线程对应的锁的重入次数,如果当前线程创建了多个不同路径的分布式锁,由于只有一个键值对,此前的LockData就会直接被新的锁对象的LockData覆盖掉导致先加的锁无法解锁,程序卡死
if ( lockData != null )//如果当前线程的LockData不为null,说明当前线程此前已经获取过锁了,直接对LockData的lockCount属性进行cas操作递增1然后上锁方法直接返回
{
// re-entering
lockData.lockCount.incrementAndGet();
return true;
}
String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());1️⃣-2️⃣ //如果当前线程的LockData不为null,调用internals.attemptLock()方法尝试获取锁,获取到锁返回lockPath,如果成功获取到锁,使用当前线程和lockPath来构造当前线程的LockData对象,LockData的构造方法中默认就使用1来赋值重入次数计数;并将该对象存入threadData,这个threadData就像老杜讲的那个ThreadLocal,老杜瞎几把讲,真实的ThreadLocal根本不是他说的那个模型,然后结束方法执行返回true表示获取锁成功
if ( lockPath != null )
{
LockData newLockData = new LockData(currentThread, lockPath);//这里面第一次获取锁是直接将锁重入计数设置为1,并向LockData中放入当前线程和锁路径[锁路径是完整的节点路径]
threadData.put(currentThread, newLockData);//将LockData对象以当前线程作为key存入保存锁重入信息的threadData即ConcurrentHashMap中,至此获取锁执行结束返回true给acquire方法结束Acquire方法的执行,后续锁重入就从该Map中获取锁重入信息直接计数加1,上面讲过了
return true;
}
//尝试获取锁失败,lockPath为null获取锁的acquire方法就直接返回false了
return false;
}
1️⃣-2️⃣ lockInternals.attemptLock(time, unit, getLockNodeBytes())
String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception
{
final long startMillis = System.currentTimeMillis();//获取系统当前时间
final Long millisToWait = (unit != null) ? unit.toMillis(time) : null;//获取锁的等待时间
final byte[] localLockNodeBytes = (revocable.get() != null) ? new byte[0] : lockNodeBytes;
int retryCount = 0;//重试次数
String ourPath = null;
boolean hasTheLock = false;
boolean isDone = false;//该变量设置为false,在下面的while循环中死循环来尝试获取锁
while ( !isDone )
{
isDone = true;//首先将isDone设置为true,如果获取锁成功就直接退出循环,获取锁失败了就将其设置为false继续死循环
try
{
ourPath = driver.createsTheLock(client, path, localLockNodeBytes);1️⃣-2️⃣-1️⃣ //通过该方法为当前要获取的锁创建一个节点并返回节点的全路径,这里面只是拼接出完成的节点路径[returnPath: "/curator/locks/_c_09c8be8f-a044-4e1d-a1c7-f50bd4571b8b-lock-"],第一次进入循环执行完下面一行代码会直接抛异常进入catch语句块设置isDone为false再次进入循环,此时Zookeeper中还没有创建对应路径的节点,在第二次进入循环执行完该代码时Zookeeper中的对应路径节点就创建出来了,所以获取锁的方法还是在该方法中,具体在哪儿老师没有追进去,可能在forPath方法前面,第一次循环发现路径格式不对所以没创建出来节点,在下面方法检查锁是否存在时发现不存在因而抛出异常再次进入循环发现路径对了
hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);1️⃣-2️⃣-2️⃣ //第一次循环debug在执行这一步的过程中直接抛出异常进入catch语句块中了,这个情况比较复杂,有第一次直接抛异常进入后续catch块的,也有第一次执行异常被自身捕获处理后返回false的,老师已经混乱开始瞎讲了,
}
catch ( KeeperException.NoNodeException e )//如果上面的代码执行出现异常就会进入catch逻辑
{
// gets thrown by StandardLockInternalsDriver when it can't find the lock node
// this can happen when the session expires, etc. So, if the retry allows, just try it all again
if ( client.getZookeeperClient().getRetryPolicy().allowRetry(retryCount++, System.currentTimeMillis() - startMillis, RetryLoop.getDefaultRetrySleeper()) )
{
isDone = false;//进入catch语句块将isDone设置为false,再次进入循环,成功获取锁不会抛异常,所以成功获取锁会直接结束循环返回lockPath并封装到threadData中
}
else
{
throw e;
}
}
}
if ( hasTheLock )
{
return ourPath;
}
return null;
}
1️⃣-2️⃣-1️⃣ standardLockInternalsDriver.createsTheLock(client, path, localLockNodeBytes);
public String createsTheLock(CuratorFramework client, String path, byte[] lockNodeBytes) throws Exception
{
String ourPath;
if ( lockNodeBytes != null )//这个判断估计是判断节点是否需要添加多余信息,根据信息有无选择不同的创建节点方法
{
ourPath = client.create().creatingParentContainersIfNeeded().withProtection().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path, lockNodeBytes);1️⃣-2️⃣-1️⃣-1️⃣ //这个代码是创建临时节点,CreateMode.EPHEMERAL_SEQUENTIAL就是指定节点的类型为临时序列化节点,这里面的forPath(path, lockNodeBytes)是将`父节点路径+子节点名称`["/curator/locks/lock-"]处理成最终的节点路径;第一次进入循环创建节点的代码可能在forPath方法前面,第一次循环发现路径格式不对所以没创建出来节点,在后面方法检查锁是否存在时发现节点没创建因而抛出异常再次进入循环发现路径对了就创建出对应的节点了
}
else
{
ourPath = client.create().creatingParentContainersIfNeeded().withProtection().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path);
}
return ourPath;
}
1️⃣-2️⃣-2️⃣ lockInternals.internalLockLoop(startMillis, millisToWait, ourPath)
private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception
{
boolean haveTheLock = false;//获取到锁的标识默认值为false
boolean doDelete = false;//执行删除的标识位也是false
try
{
if ( revocable.get() != null )
{
client.getData().usingWatcher(revocableWatcher).forPath(ourPath);
}
while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock )//检查客户端状态是否已经启动的状态,如果客户端已经启动且没有获取到锁的情况下进入while循环
{
List<String> children = getSortedChildren();//获取用户指定父节点下的所有子节点列表,这个第一次获取子节点列表失败,获取到的是空列表;但是第二次获取成功
String sequenceNodeName = ourPath.substring(basePath.length() + 1); // +1 to include the slash,从完整的节点名称中截取出节点不包含父节点路径的名称
PredicateResults predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases);1️⃣-2️⃣-2️⃣-1️⃣ //判断当前线程对应的序列化节点的序列号是不是子节点列表中最小的,如果是最小的可以获取锁,返回predicateResults对象,里面封装了pathToWatch, getsTheLock属性,如果获取到锁getsTheLock属性就为true,如果没获取到锁就返回前驱节点的名字
if ( predicateResults.getsTheLock() )
{
haveTheLock = true;//如果获取到锁haveTheLock被设置为true会作为该方法的返回值并在上级方法中返回lockPath并在1️⃣方法中封装进threadData的LockData中
}
else//如果获取锁失败就会进入下面给前驱节点设置监听事件的逻辑
{
String previousSequencePath = basePath + "/" + predicateResults.getPathToWatch();
synchronized(this)
{
try
{
// use getData() instead of exists() to avoid leaving unneeded watchers which is a type of resource leak
client.getData().usingWatcher(watcher).forPath(previousSequencePath);//在这一步给当前节点下标-最大租约数对应下标的节点添加删除事件监听,这里默认最大租约数为1,所以是给前一个节点添加删除监听事件,官方注释使用getData方法而避免使用exists方法是为了避免不必要的监听导致资源泄露,老师的解释是exists方法不管节点是否存在都会去执行监听,getData节点不存在就不会去监听,那前面自己的实现不是使用的Zookeeper官方客户端的exists方法来检查前驱节点和设置监听事件的吗,那不是也会设置监听事件导致客户端资源泄露,老师承认了之前那个就是exists会存在该问题
if ( millisToWait != null )
{
millisToWait -= (System.currentTimeMillis() - startMillis);
startMillis = System.currentTimeMillis();
if ( millisToWait <= 0 )
{
doDelete = true; // timed out - delete our node
break;
}
wait(millisToWait);
}
else
{
wait();
}
}
catch ( KeeperException.NoNodeException e )
{
// it has been deleted (i.e. lock released). Try to acquire again
}
}
}
}
}
catch ( Exception e )
{
ThreadUtils.checkInterrupted(e);
doDelete = true;
throw e;
}
finally
{
if ( doDelete )
{
deleteOurPath(ourPath);//[ourPath: "完整的节点路径"],老师说这里节点验证失败把节点删了,我觉得是他自己都混乱了,真特么难顶啊,这里讲的不清楚;这里只有出现异常才会删除对应路径的节点;成功获取锁doDelete为默认值不会删除对应节点
}
}
return haveTheLock;//第一次执行这里因为抛异常直接返回false,获取锁成功会返回true,返回true作为返回lockPath的判断标志
}
1️⃣-2️⃣-1️⃣-1️⃣ createBuilderImpl.forPath(path, lockNodeBytes)//[path: "/curator/locks/lock-"]
public String forPath(String path) throws Exception
{
return forPath(path, client.getDefaultData());1️⃣-2️⃣-1️⃣-1️⃣-1️⃣ //进入重载的forPath方法
}
1️⃣-2️⃣-2️⃣-1️⃣ standardLockInternalsDriver.getsTheLock(client, children, sequenceNodeName, maxLeases)
public PredicateResults getsTheLock(CuratorFramework client, List<String> children, String sequenceNodeName, int maxLeases) throws Exception
{
int ourIndex = children.indexOf(sequenceNodeName);//获取当前节点名字在父节点下的排序后的子节点列表中的下标
validateOurIndex(sequenceNodeName, ourIndex);//验证下标,讲的一坨shit,[sequenceNodeName: _c_09c8be8f-a044-4e1d-a1c7-f50bd4571b8b-lock-序列号],第一次验证下标的过程中抛出异常被1️⃣-2️⃣-2️⃣ 中的catch语句捕获,并将删除标识置为true执行deleteOurPath(ourPath);方法;第二次进来子节点列表存在,验证节点名称和节点下标没抛异常,老师的意思是第一次验证失败把节点删了又重新创建了一次,不知道,我感觉他已经凌乱了,我感觉这里是检验前驱节点是否还存在于Zookeeper服务器的代码
boolean getsTheLock = ourIndex < maxLeases;//如果当前节点的下标小于最大租约,默认值是1,如果是小于就将getsTheLock变量即返回值对象PredicateResults的getsTheLock属性设置为true,表示已经拿到锁而且此时不需要监听前驱节点;如果当前节点下标大于最大租约数说明需要阻塞,此时getsTheLock属性设置为false,且获取前驱节点的名字,这里其实是获取比当前下标小最大租约数的节点名字来进行监听
String pathToWatch = getsTheLock ? null : children.get(ourIndex - maxLeases);
return new PredicateResults(pathToWatch, getsTheLock);
}
1️⃣-2️⃣-1️⃣-1️⃣-1️⃣ createBuilderImpl.forPath(path, client.getDefaultData())
public String forPath(final String givenPath, byte[] data) throws Exception
{
if ( compress )
{
data = client.getCompressionProvider().compress(givenPath, data);
}
final String adjustedPath = adjustPath(client.fixForNamespace(givenPath, createMode.isSequential()));1️⃣-2️⃣-1️⃣-1️⃣-1️⃣-1️⃣ //这里获取到的路径才是节点真正的路径,debug对于[path: "/curator/locks/lock-"]的返回结果是[adjustedPath: "/curator/locks/_c_09c8be8f-a044-4e1d-a1c7-f50bd4571b8b-lock-"],即在lock-前面加了一个`_c_uuid-`,因此节点最终的路径为"用户指定父节点/_c_uuid-lock-ZK生成的序列号",其实这个方法就做了一个字符串拼接,没什么技术含量,主要还是根据protectedMode.doProtected属性如果为true才去拼接最终的节点路径
List<ACL> aclList = acling.getAclList(adjustedPath);//获取acl权限列表
client.getSchemaSet().getSchema(givenPath).validateCreate(createMode, givenPath, data, aclList);//验证路径是否合法
String returnPath = null;
if ( backgrounding.inBackground() )
{
pathInBackground(adjustedPath, data, givenPath);
}
else
{
String path = protectedPathInForeground(adjustedPath, data, aclList);
returnPath = client.unfixForNamespace(path);
}
return returnPath;//返回最终的路径[returnPath: "/curator/locks/_c_09c8be8f-a044-4e1d-a1c7-f50bd4571b8b-lock-"]
}
1️⃣-2️⃣-1️⃣-1️⃣-1️⃣-1️⃣ createBuilderImpl.adjustPath(client.fixForNamespace(givenPath, createMode.isSequential()))
String adjustPath(String path) throws Exception//这里传进来的path值为[path: "/curator/locks/lock-"]
{
if ( protectedMode.doProtected() )//doProtected()方法是返回protectedMode对象中的布尔类型属性doProtected,该属性属性值默认为false,当createBuilderImpl对象实例化时会根据构造方法传参的布尔类型参数doProtected决定是否将protectedMode.doProtected属性调用protectedMode.setProtectedMode()将其改为true并生成UUID存入protectedMode.protectedId中,protectedMode对象在createBuilderImpl的protectedMode属性声明时就创建出来了
{
ZKPaths.PathAndNode pathAndNode = ZKPaths.getPathAndNode(path);//传参[path: "/curator/locks/lock-"]返回ZKPaths.PathAndNode对象,该对象中主要有两个属性,其中path属性是用户自定义的父节点路径,node属性是固定的`lock-`字符串
String name = getProtectedPrefix(protectedMode.protectedId()) + pathAndNode.getNode();//在ZKPaths.PathAndNode对象的node属性前拼上一堆前缀,这里看到了protectedMode.protectedId()就是前面提到的UUID,该UUID在createBuilderImpl初始化时生成,而且getProtectedPrefix()方法还在UUID前面加了_c_,`_c_`是以字符串常量PROTEGTED_PREFIX存在于CreateBuilderImpl中的,getProtectedPrefix()方法拼接的是PROTEGTED_PREFIX+protectedMode.protectedId()+"-"
path = ZKPaths.makePath(pathAndNode.getPath(), name);//再把ZKPaths.PathAndNode对象的path属性即用户自定义的父节点路径直接拼接到PROTEGTED_PREFIX+protectedMode.protectedId()+"-"+"lock-"前面再加一个"/"
}
return path;//返回Sting类型的该路径一路到分支1️⃣-2️⃣-1️⃣ 处
}
解锁方法
核心:释放锁的代码和我们自己实现的代码是一样的,释放锁先从ConcurrentHashMap
中获取重入数据,直接对锁重入
xxxxxxxxxx
lockInternals.release()
/**
* Perform one release of the mutex if the calling thread is the same thread that acquired it. If the
* thread had made multiple calls to acquire, the mutex will still be held when this method returns.
*
* @throws Exception ZK errors, interruptions, current thread does not own the lock
*/
public void release() throws Exception
{
/*
Note on concurrency: a given lockData instance
can be only acted on by a single thread so locking isn't necessary
*/
Thread currentThread = Thread.currentThread();
LockData lockData = threadData.get(currentThread);//通过当前线程从ConcurrentHashMap中获取锁重入状态LockData对象,如果LockData对象为null说明没获取到锁就在解锁,此时直接抛出非法监听器状态异常
if ( lockData == null )
{
throw new IllegalMonitorStateException("You do not own the lock: " + basePath);
}
//如果LockData对象不为null,就直接进行cas-1操作,如果减1后锁重入计数大于0直接返回,如果锁重入计数小于0直接抛非法监听器状态异常,如果等于0就调用internals.releaseLock(lockData.lockPath);方法尝试删除节点释放锁,并且从ConcurrentHashMap中移除当前线程为key的键值对
int newLockCount = lockData.lockCount.decrementAndGet();
if ( newLockCount > 0 )
{
return;
}
if ( newLockCount < 0 )
{
throw new IllegalMonitorStateException("Lock count has gone negative for lock: " + basePath);
}
try
{
internals.releaseLock(lockData.lockPath);1️⃣ //去除监听,并删除对应路径下的节点
}
finally
{
threadData.remove(currentThread);
}
}
1️⃣ lockInternals.releaseLock(lockData.lockPath)
final void releaseLock(String lockPath) throws Exception
{
client.removeWatchers();//去除监听
revocable.set(null);
deleteOurPath(lockPath);//将当前线程对应路径的节点删除
}
InterProcessSemaphoreMutex
是Curator提供的基于Zookeeper
实现的分布式不可重入锁,虽然名字中写了一个Semaphore,但是和信号量的概念没什么关系,该对象构造方法和加锁解锁方法和InterProcessMutex
是一模一样的,InterProcessSemaphoreMutex
和InterProcessMutex
都实现了InterProcessLock
接口
InterProcessSemaphoreMutex
除了不可重入特性外,构造、加锁和解锁方法和InterProcessMutex
是一模一样的
常用API
public InterProcessSemaphoreMutex(CuratorFramework client, String path);
功能解析:InterProcessSemaphoreMutex
的构造方法,传参用户自定义的CuratorFramework
对象和父节点路径path
使用示例:InterProcessSemaphoreMutex mutex = new InterProcessSemaphoreMutex(curator, path);
示例含义:构造分布式不可重入锁示例以获取锁对象
void ---> interProcessSemaphoreMutex.acquire();
功能解析:获取锁interProcessSemaphoreMutex
使用示例:mutex.acquire();
示例含义:阻塞直到获取到分布式锁
boolean ---> interProcessSemaphoreMutex.acquire(long time, TimeUnit unit);
功能解析:带超时机制的获取锁InterProcessSemaphoreMutex
,如果到阻塞指定时间仍没有获取到锁放弃等待锁
使用示例:mutex.acquire(10, TimeUnit.SECONDS);
示例含义:十秒内尝试获取锁,超过十秒未获取放弃获取锁
void ---> release();
功能解析:释放锁interProcessSemaphoreMutex
使用示例:mutex.release()
示例含义:释放已占有的锁
用法示例
xxxxxxxxxx
public class StockServiceImpl implements StockService {
/**
* 商品库存必须是单例的,service是单例的,其中的成员变量就一定是单例的
*/
private Stock stock = new Stock();
private StringRedisTemplate redisTemplate;
private CuratorFramework curatorFrameworkClient;
public void deduct() {
//初始化InterProcessSemaphoreMutex实例,锁的父节点路径为/curator/locks
InterProcessSemaphoreMutex mutex = new InterProcessSemaphoreMutex(curatorFramework, "/curator/locks");
try {
//尝试获取锁,阻塞直到获取锁
mutex.acquire();
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
//释放锁
mutex.release();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
Curator提供的基于Zookeeper实现的分布式可重入读写锁的特点就是读读并发、读写不可以并发、写写不可以并发
读锁重入的时候不支持升级,即持有读锁的条件下当前线程不能去尝试获取写锁,这种代码会直接导致写锁永久等待程序卡死,即读锁被占有期间写锁永远无法被获取,但是支持重入时降级,即先获取写锁的情况下同一个线程还能获取读锁,这也满足写锁符合AQS实现的锁的行为,只有读锁才会阻塞相同读写锁对应写锁的获取
常用API
public InterProcessReadWriteLock(CuratorFramework client, String basePath);
功能解析:InterProcessReadWriteLock
的构造方法,传参用户自定义的CuratorFramework
对象和父节点路径path
使用示例:InterProcessReadWriteLock rwlock = new InterProcessReadWriteLock(curatorFramework, "/curator/rwlock");
示例含义:构造父节点路径为/curator/rwlock
分布式可重入读写锁示例以获取锁对象
InterProcessMutex ---> interProcessReadWriteLock.readLock();
功能解析:从读写锁interProcessReadWriteLock
中获取读锁
使用示例:rwlock.readLock().acquire(10, TimeUnit.SECONDS);
示例含义:从读写锁获取读锁并使用过10s自动解锁的方式来释放锁
补充说明:
读锁和写锁都创建的是传参不同的InterProcessReadWriteLock
下的静态内部类InternalInterProcessMutex
对象,该静态内部类继承自InterProcessMutex
这个读写锁的acquire(10, TimeUnit.SECONDS)
方法和Redisson中的读写锁RReadWriteLock
的lock(10, TimeUnit.SECONDS)
一样意思不是带等待超时时间的加锁,而是获取锁以后过10s自动释放锁,一定要注意这点
InterProcessMutex ---> interProcessReadWriteLock.writeLock();
功能解析:从读写锁interProcessReadWriteLock
中获取写锁
使用示例:rwlock.writeLock().acquire(10, TimeUnit.SECONDS);
示例含义:从读写锁获取写锁并上锁,获取锁10s钟以后自动释放锁
boolean ---> internalInterProcessMutex.acquire(long time, TimeUnit unit);
功能解析:从读写锁interProcessReadWriteLock
中获取写锁或者读锁,并从获取锁开始到指定时间后自动释放锁
使用示例:rwlock.writeLock().acquire(10, TimeUnit.SECONDS);
或rwlock.readLock().acquire(10, TimeUnit.SECONDS);
示例含义:从读写锁获取写锁并上锁,获取锁10s钟以后自动释放锁或从读写锁获取读锁并上锁,获取锁10s钟以后自动释放锁
补充说明:
读锁和写锁都是InterProcessReadWriteLock
中继承自InterProcessMutex
的静态内部类internalInterProcessMutex
,他的加锁acquire()
方法和解锁release()
方法都来自于父类InterProcessMutex
,该加锁方法的意思是获取锁等待指定时间自动释放锁,即使当前线程任务执行完了也要阻塞等到10s钟以后锁自动释放了才能将请求返回给用户
这个读写锁的acquire(10, TimeUnit.SECONDS)
方法和Redisson中的读写锁RReadWriteLock
的lock(10, TimeUnit.SECONDS)
一样意思不是带等待超时时间的加锁,而是获取锁以后过10s自动释放锁,一定要注意这点
void ---> internalInterProcessMutex.acquire();
功能解析:从读写锁interProcessReadWriteLock
中获取写锁或读锁,这种上锁方式必须手动释放锁
使用示例:rwlock.writeLock().acquire(10, TimeUnit.SECONDS);
或rwlock.readLock().acquire(10, TimeUnit.SECONDS);
示例含义:从读写锁获取写锁并上锁或从读写锁获取读锁并上锁
补充说明:
这种无参上锁方式必须手动释放锁
void ---> internalInterProcessMutex.release();
功能解析:写锁或读锁手动释放锁
使用示例:rwlock.writeLock().release();
或rwlock.readLock().release();
示例含义:写锁主动释放锁或读锁主动释放锁
用法示例
注意:读锁即使调用了到指定时间释放锁的acquire(10, TimeUnit.SECONDS)
方法,但是请求线程业务执行完会立即将结果返回给用户,不会阻塞一直等待读锁被自动释放;但是如果写锁调用了到指定时间释放锁的acquire(10, TimeUnit.SECONDS)
方法,请求线程即使执行完业务方法但是锁还没有到时间被自动释放掉,请求线程会一直阻塞不响应给用户直到写锁被自动释放,每次都等10s待锁释放以后再将响应结果返回给用户
而且由于写锁会阻塞读锁,写锁没有被自动释放在10s等待期间内,请求线程无法获取到读锁,用户线程也会被阻塞到写锁自动释放以后才能立即获取读锁,执行完业务方法后返回响应结果,此时用户请求线程无需等到读锁到10s自动释放就能响应给客户端
同时读锁也会阻塞写锁,用户线程获取读锁并使用acquire(10, TimeUnit.SECONDS)
方法上锁,因为读锁不会因为锁还没到指定时间自动释放锁阻塞用户请求线程,因此获取读锁的用户线程执行完业务方法会立即响应,但是此时读锁并没有释放,还是要等到指定10s时间才会自动释放,如果在获取读锁的用户请求刚到达服务器就紧跟着一个获取写锁的用户请求,获取写锁的用户请求会首先被前一个请求的读锁阻塞10s无法获取写锁,然后还会被写锁到达指定10s时间自动释放写锁再阻塞10s,用户获取写锁的请求线程从请求发出到收到响应一共会被阻塞20s的时间
这是InterProcessReadWriteLock可重入读写锁
相对于其他第三方提供的读写锁的一个独特特征,即写锁在释放之前会一直阻塞请求线程,而读锁不会,一定要特别注意
xxxxxxxxxx
public class StockController {
private StockServiceImpl stockService;
"test/zk/read/lock") (
public String testZKReadLock(){
stockService.testZkReadLock();
return "测试读锁";
}
"test/zk/write/lock") (
public String testZKWriteLock(){
stockService.testZkWriteLock();
return "测试写锁";
}
}
public class StockServiceImpl implements StockService {
/**
* 商品库存必须是单例的,service是单例的,其中的成员变量就一定是单例的
*/
private Stock stock = new Stock();
private StringRedisTemplate redisTemplate;
private CuratorFramework curatorFrameworkClient;
public void testZkReadLock() {
try {
InterProcessReadWriteLock rwlock = new InterProcessReadWriteLock(curatorFramework, "/curator/rwlock");
//注意这不是带超时时间的获取锁,这是拿到锁10s钟以后自动释放锁,不到10s钟即使方法都执行完了锁也不会释放,请求线程就会在解锁那里一直阻塞
rwlock.readLock().acquire(10, TimeUnit.SECONDS);
// TODO:一顿读的操作。。。。
//如果使用一直阻塞直到获取锁的方式就需要手动释放锁
//rwlock.readLock().release();
} catch (Exception e) {
e.printStackTrace();
}
}
public void testZkWriteLock() {
try {
InterProcessReadWriteLock rwlock = new InterProcessReadWriteLock(curatorFramework, "/curator/rwlock");
rwlock.writeLock().acquire(10, TimeUnit.SECONDS);
// TODO:一顿写的操作。。。。
//如果使用一直阻塞直到获取锁的acquire()方式就需要使用release()手动释放锁
//rwlock.writeLock().release();
} catch (Exception e) {
e.printStackTrace();
}
}
}
类似于Redisson中的联锁对象,可以将多把锁关联到一起,调用acquire方法会对关联的所有锁加锁,当所有的锁都加锁成功,联锁才算加锁成功;只要有一把锁加锁失败就算联锁加锁失败,需要调用release方法对所有的锁进行释放,加锁失败的锁会被自动忽略,这个锁没啥用,工作中一般也不会使用
常用API
xxxxxxxxxx
// 构造函数需要包含的锁的集合,或者一组ZooKeeper的path
public InterProcessMultiLock(List<InterProcessLock> locks);
public InterProcessMultiLock(CuratorFramework client, List<String> paths);
// 获取锁
public void acquire();
public boolean acquire(long time, TimeUnit unit);
// 释放锁
public synchronized void release();
和Redisson中的信号量的设计思想和应用场景是一样的,都是限制访问某个共享资源的线程数
常用API
public InterProcessSemaphoreV2(CuratorFramework client, String path, int maxLeases)
功能解析:InterProcessSemaphoreV2
的构造方法,传参用户自定义的CuratorFramework
对象、父节点路径path
和该实例允许的最大租借数量
使用示例:InterProcessSemaphoreV2 semaphoreV2 = new InterProcessSemaphoreV2(curatorFramework, "/locks/semaphore", 5)
示例含义:构造父节点路径为/locks/semaphore
的分布式信号量,设置最大可允许同时运行的线程数为5
补充说明:leases
的意思就是租约的意思,表示租赁的契约
Lease ---> interProcessSemaphoreV2.acquire()
功能解析:获取运行许可,只有当剩余可允许运行线程数大于0时才能成功获取到许可执行后续代码,否则当前线程就在获取许可处阻塞等待其他线程释放许可,获取成功返回一个租约对象Lease
使用示例:Lease acquire = semaphoreV2.acquire()
示例含义:当前线程尝试获取运行许可,并返回获取到的租约对象
void ---> interProcessSemaphoreV2.returnLease(Lease lease)
功能解析:释放当前线程占有的运行许可,需要传参当前线程获取许可成功时的返回租约对象lease
使用示例:semaphoreV2.returnLease(acquire)
示例含义:当前线程释放已经占有的运行许可
代码示例
xxxxxxxxxx
public class StockServiceImpl implements StockService {
private StringRedisTemplate redisTemplate;
private CuratorFramework curatorFrameworkClient;
public void testSemaphore() {
// 设置资源量 限流的线程数,这里的5是参数最大租约数maxLeases的参数值,
InterProcessSemaphoreV2 semaphoreV2 = new InterProcessSemaphoreV2(curatorFramework, "/locks/semaphore", 5);
try {
Lease acquire = semaphoreV2.acquire();// 获取资源,获取资源成功的线程可以继续处理业务操作。否则会被阻塞住
redisTemplate.opsForList().rightPush("log", "10010获取了资源,开始处理业务逻辑。" + Thread.currentThread().getName());
TimeUnit.SECONDS.sleep(10 + new Random().nextInt(10));
redisTemplate.opsForList().rightPush("log", "10010处理完业务逻辑,释放资源=====================" + Thread.currentThread().getName());
semaphoreV2.returnLease(acquire); // 手动释放资源,后续请求线程就可以获取该资源
} catch (Exception e) {
e.printStackTrace();
}
}
Curator
中没有闭锁CountdownLatch
这种东西,但是有一个类似的共享计数器的东西,共享计数器提供了一个ShareCount
类和一个接口DistributedAtomicNumber
,该接口有两个实现类DistributedAtomicLong
和DistributedAtomicInteger
,这两个实现类除了数据类型不同,使用方式都是一样的
共享计数器的设计理念是在多个服务器之间共享某个通用的计数,就是系统内的所有服务器都可以通过相同路径的共享计数器拿到共享计数器中的共享计数值且都可以对该值进行修改
共享计数器也可以实现CountdownLatch
的效果,实现起来比较麻烦
常用API
public SharedCount(CuratorFramework client, String path, int seedValue)
功能解析:实例化共享计数器SharedCount
对象并指定该对象的父节点路径和计数初始值
使用示例:SharedCount sharedCount = new SharedCount(curatorFrameworkClient, "/curator/count", 0);
示例含义:构造父节点路径为/curator/count
的共享计数对象并指定初始计数值为0
void ---> sharedCount.start()
功能解析:共享计数器手动启动,只有手动启动了的sharedCount
对象才能使用完整的功能
使用示例:sharedCount.start();
示例含义:手动启动sharedCount
对象
int ---> sharedCount.getCount()
功能解析:获取共享计数对象中的共享值
使用示例:int count = sharedCount.getCount();
示例含义:获取共享计数对象的共享值
void ---> sharedCount.setCount(int newCount)
功能解析:修改共享计数对象中的共享值
使用示例:sharedCount.setCount(random);
示例含义:将共享计数器SharedCount
对象的共享值设定为指定值
void ---> sharedCount.close()
功能解析:使用完SharedCount
对象需要调用close方法手动进行关闭
使用示例:sharedCount.close()
示例含义:手动关闭共享计数器
boolean ---> sharedCount.trySetCount(int newCount)
功能解析:当版本号没有变化时,才会更新共享变量的值
使用示例:``
示例含义:
补充说明:该方法官方标注已过时
void ---> sharedCount.addListener(final SharedCountListener listener)
功能解析:给共享计数对象添加监听器,将来一旦共享值发生变化,监听器可以监听到值的变化
使用示例:
xxxxxxxxxx
// 监听节点添加监听器
sharedCount.addListener(new SharedCountListener(){
public void stateChanged(CuratorFramework client, ConnectionState newState) {}
public void countHasChanged(SharedCountReader sharedCount, int newCount) throws Exception {
System.out.println("[Listener] : 共享整数被修改成 " + newCount);
}
});
示例含义:共享值发生变化时打印被修改后的共享值
补充说明:SharedCount
的该方法在日常使用中不是特别频繁,原因是另外一个类DistributedAtomicNumber
对应的功能更加强大灵活
用法示例
xxxxxxxxxx
"test/shared/count") (
public String testSharedCount(){
stockService.testSharedCount();
return "测试共享计数器";
}
public class StockServiceImpl implements StockService {
public void testZKSharedCount() {
try {
// 第三个参数是共享计数的计数初始值
SharedCount sharedCount = new SharedCount(curatorFrameworkClient, "/curator/count", 0);
// 必须手动调用start方法启动共享计数器,否则其中的某些功能是无法使用的
sharedCount.start();
// 获取共享计数的值
int count = sharedCount.getCount();
// 指定一个1000以内的随机值
int random = new Random().nextInt(1000);
//可以通过setCount方法修改共享计数器的值
sharedCount.setCount(random);
System.out.println("我获取了共享计数的初始值:" + count + ",并把计数器的值改为:" + random);
//手动关闭共享计数器
sharedCount.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
该接口有两个实现类DistributedAtomicLong
和DistributedAtomicInteger
,两个实现类的方法都是相同的,只是共享数据的最大范围不同,一般DistributedAtomicLong
更加通用
DistributedAtomicNumber
比SharedCount
更简单易用。它首先尝试使用乐观锁的方式设置计数器, 如果不成功【比如期间计数器已经被其它client更新了】, 就会使用InterProcessMutex
方式来更新计数值
使用DistributedAtomicLong
的increment()
方法和decrement()
方法来实现CountDownLatch
的功能就相对比较容易
大多数方法的返回值是AtomicValue<Long>
类型,操作成功与否的结果保存在返回值对象的属性中,可以通过atomicValue.succeeded()
方法获取到该属性的值,该值为true
表示操作成功,在succeeded
属性为true
即操作成功的前提下,atomicValue.preValue()
代表操作前的计数值,atomicValue.postValue()
代表操作后的计数值,一般程序中都要通过返回值判断本次操作是否成功,如果不成功再采取进一步动作
API
public DistributedAtomicLong(CuratorFramework client, String counterPath, RetryPolicy retryPolicy)
功能解析:实例化DistributedAtomicLong
对象并指定该对象的父节点路径和重试策略
使用示例:``
示例含义:
AtomicValue<Long> ---> distributedAtomicLong.trySet(Long newValue)
功能解析:尝试给DistributedAtomicLong
对象设置计数值
使用示例:``
示例含义:
boolean ---> distributedAtomicLong.initialize(Long initialize)
功能解析:给DistributedAtomicLong
对象的计数值设置初始值
使用示例:``
示例含义:
AtomicValue<Long> ---> distributedAtomicLong.get()
功能解析:获取DistributedAtomicLong
对象的计数值
使用示例:``
示例含义:
void ---> distributedAtomicLong.forceSet(Long newValue)
功能解析:给DistributedAtomicLong
对象的计数值强制设置为新值newValue
,trySet()
方法可能会设置失败,但是forceSet()
方法一定能设置成功
使用示例:``
示例含义:
AtomicValue<Long> ---> distributedAtomicLong.increment()
功能解析:让DistributedAtomicLong
对象的计数值增加1
使用示例:``
示例含义:
AtomicValue<Long> ---> distributedAtomicLong.decrement()
功能解析:让DistributedAtomicLong
对象的计数值减去1
使用示例:``
示例含义:
AtomicValue<Long> ---> distributedAtomicLong.add(Long delta)
功能解析:让DistributedAtomicLong
对象的计数值加上指定值delta
使用示例:``
示例含义:
Redis基于同一个key的键值对唯一性实现锁的独占排他、Zookeeper基于相同路径的节点的唯一性实现锁的独占排他、相似的Mysql可以使用唯一键索引的特征来实现锁的独占排他
为分布式锁专门设计一张表,只需要设计bigint
类型的非空自增主键id
和varchar
类型的非空锁名称lock_name
两个字段,要针对字段lock_name
设置唯一键索引,当有多个并发请求来执行插入语句INSERT INTO t_distributed_lock(lock_name) value ('大裤衩出货')
只有一个请求能执行成功,后续请求因为不满足唯一键索引会导致执行失败并且抛异常,当插入成功的请求线程相当于获取到锁执行业务方法然后通过删除该条记录来释放锁,竞争锁失败的线程进行等待重试
基于Mysql实现的分布式锁在企业开发中使用的很少,因为这种方式存在严重的性能问题
环境搭建
分布式锁表结构设计参考
设置lock_name
字段唯一键索引,其他字段按需自行设计
xxxxxxxxxx
CREATE TABLE `t_distributed_lock` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`lock_name` varchar(50) NOT NULL COMMENT '锁名',
`class_name` varchar(100) DEFAULT NULL COMMENT '类名',
`method_name` varchar(50) DEFAULT NULL COMMENT '方法名',
`server_name` varchar(50) DEFAULT NULL COMMENT '服务器ip',
`thread_name` varchar(50) DEFAULT NULL COMMENT '线程名',
`create_time` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '获取锁时间',
`lock_count` int NOT NULL COMMENT '锁重入计数',
`desc` varchar(100) DEFAULT NULL COMMENT '描述',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_unique` (`lock_name`)
) ENGINE=InnoDB AUTO_INCREMENT=1332899824461455363 DEFAULT CHARSET=utf8;
设计对应实体类
xxxxxxxxxx
"t_distributed_lock") (
public class Lock {
private Long id;
private String lockName;
private String className;
private String methodName;
private String serverName;
private String threadName;
private Date createTime;
private String desc;
}
对应的Mapper接口
这是引入MyBatisPlus
来简化单表sql情况下的配置
xxxxxxxxxx
public interface LockMapper extends BaseMapper<Lock> {
}
分布式锁代码实现
这里没有对锁进行封装,这也是非常简陋的版本,没有考虑死锁、重入、续期、公平、正确释放锁、阻塞队列等一系列问题,因为这种实现因为性能问题企业开发不常用
xxxxxxxxxx
public class StockService {
private StringRedisTemplate redisTemplate;
private LockMapper lockMapper;
/**
* 数据库分布式锁
*/
public void checkAndLock() {
// 加锁
Lock lock = new Lock(null, "lock", this.getClass().getName(), new Date(), null);
try {
this.lockMapper.insert(lock);//因为唯一键约束导致插入失败会直接抛出异常,这个异常太粗了,可以优化,插入成功MyBatisPlus会根据生成的记录对实体对象的主键id进行回写
} catch (Exception ex) {
// 获取锁失败,则重试
try {
Thread.sleep(50);
this.checkAndLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 1. 查询库存信息
String stock = redisTemplate.opsForValue().get("stock").toString();
// 2. 判断库存是否充足
if (stock != null && stock.length() != 0) {
Integer st = Integer.valueOf(stock);
if (st > 0) {
// 3.扣减库存
redisTemplate.opsForValue().set("stock", String.valueOf(--st));
}
}
// 释放锁
this.lockMapper.deleteById(lock.getId());//删除记录通过此前获取锁回写的主键id来删防止误删
}
}
测试
测试环境:
1️⃣:100个用户线程,1s内每个用户线程发起50次扣减库存请求,总共发起5000次,场景为请求连接nginx负载均衡两个运行实例操作虚拟机上的同一个Redis数据库的同一个共享数据,使用上述基于mysql实现的分布式锁对库存数量5000进行单次扣减1,累计5000次扣减请求
测试结果:
1️⃣:吞吐量194,5000次扣减操作全部成功,Redis中库存数据应从5000减至0,实际是0,没有出现线程安全问题
借助唯一键索引使用插入语句通过成功创建记录竞争锁
客户端程序获取到锁后宕机来不及释放锁可能导致死锁
可以通过给分布式锁表添加锁的创建时间create_time
字段,通过定时调度任务来定时检查锁的生成时间到当前时间的间隔是否超过某个阈值,超过阈值就把对应的锁自动释放掉来避免服务器宕机引起死锁
老师说这种防死锁必须是一个独立的服务来实现该功能才能实现宕机导致的死锁,不能在应用程序客户端里面,估计是不同服务的系统时间有差异,老师没说原因
不可重入锁也可能导致死锁
可以通过给分布式表添加一个服务器id
使用uuid
来对服务器进行标识,添加一个线程id
字段用来记录是哪一个线程获取到锁,添加一个锁重入计数lock_count
字段来记录当前线程的锁重入次数,发生重入就直接去更新对应的所重入计数字段lock_count
,否则就去竞争锁
通过MyBatisPlus的主键回写和通过主键删除记录在不考虑锁续期的情况下当前实现就能防误删
目前的插入一条记录和删除一条记录都是单句sql
,mysql
针对这种修改sql会自动开启本地事务,事务的底层也是通过锁实现的,能保证原子性,但是要实现可重入就可能需要使用多条SQL才能完成获取锁或者释放锁的操作,此时可以借助mysql
的悲观锁来保证多步操作的原子性
可重入已经在防死锁中分析过了
自动续期通过当前服务用定时任务重置锁创建时间即可,锁失效要么锁被释放,要么被防死锁服务自动删除
单点故障问题可以通过搭建mysql的主从集群来解决,但是mysql的主从集群锁失效的情况和Redis很类似,也是一样存在的;备用服务器还没来得及同步主服务器数据,主服务器就挂掉了,从升级为主丢失原来的锁数据,这个问题也不太好解决
阻塞锁在mysql中也比较难实现
受制于数据库性能,基于mysql实现的分布式锁并发能力很有限,这个问题也几乎无法解决
从分布式锁实现难以程度上来说
基于mysql的分布式锁实现最容易,只需要设计一张表使用插入语句和删除语句就能实现分布式锁,而且一般应用程序都会使用到数据库
基于Redis实现的分布式锁稍微麻烦一点,需要使用lua和Reids服务器
基于ZK实现的分布式锁因为Zookeeper客户端的复杂性,在自己实现分布式锁的时候相对比较麻烦,而且还需要Zookeeper服务器,一般还得是集群
从性能上来说
基于Redis实现的分布式锁性能最好,我们自己实现的吞吐量也能轻松上600,使用Redisson提供的RedissonLock
甚至可以接近900
基于ZK实现的分布式锁性能略差,我们自己实现的吞吐量550左右,Curator提供的InterProcessMutex
不清楚具体原因吞吐量只有450左右
基于Mysql的实现即使在最简易的实现下也只有190的并发量,还没有考虑各种必要功能实现
从可靠性方面来说
基于Zookeeper的实现可靠性最高,因为Zookeeper本身就是分布式系统协调组件,Zookeeper集群是强一致性性集群,集群环境下不容易发生锁数据未及时同步导致的锁失效问题
基于Redis和mysql实现的分布式锁在集群环境下可靠性是差不多的,因为都是通过网络IO,将主机操作通过多次IO同步到从机上,一旦主机宕机且锁数据没来得及同步上,就会发生锁失效问题
根据以上特征要结合项目应用场景的要求来选择合适的方案
比如简单场景,并发量不高的情况下使用基于mysql的实现几行代码就能实现一个分布式锁
追求性能,并发量高就优先选择基于Redis的实现
如果追求可靠性可以考虑基于ZK实现的分布式锁
预热慢的原因之一是很多实例对象都是懒加载的,第一次运行会去创建这些实例对象,因此第一次运行的速度会慢一些,此外还有JIT即时编译器对部分代码片段的运行优化也会提升系统的运行效率
老师说不推荐实际工作中通过线程池工具类Executors
来初始化线程池,可能会导致内存溢出,Alibaba的开发规范也不推荐使用该工具类来初始化线程池,老师说应该通过构造方法来初始化,关注以下定时调度任务和分布式定时调度任务相关的专题
不要使用springframework
的StringUtils
,老师说这个StringUtils
很垃圾,推荐使用commons-lang3
的StringUtils
关注java.util.Collections
中的Collectiosns.sort(nodes)
排序方法和Collections.binarySearch()
获取节点下标方法,总之关注一下java.util.Collections
类的使用方法
关注commons-lang3
下的StringUtils
类中的api用法
常见锁分类
悲观锁:具有强烈的独占和排他特性,在整个数据处理过程中,将数据处于锁定状态。适合于写比较多,会阻塞读操作。
乐观锁:采取了更加宽松的加锁机制,大多是基于数据版本( Version )及时间戳来实现。。适合于读比较多,不会阻塞读
独占锁、互斥锁、排他锁:保证在任一时刻,只能被一个线程独占排他持有。synchronized、ReentrantLock
共享锁:可同时被多个线程共享持有。CountDownLatch到计数器、Semaphore信号量
可重入锁:又名递归锁。同一个线程在外层方法获取锁的时候,在进入内层方法时会自动获取锁。
不可重入锁:例如早期的synchronized
公平锁:有优先级的锁,先来先得,谁先申请锁就先获取到锁
非公平锁:无优先级的锁,后来者也有机会先获取到锁
自旋锁:当线程尝试获取锁失败时(锁已经被其它线程占用了),无限循环重试尝试获取锁
阻塞锁:当线程尝试获取锁失败时,线程进入阻塞状态,直到接收信号后被唤醒。在竞争激烈情况下,性能较高
读锁:读读并发的共享锁
写锁:读写互斥,写写互斥的独占排他锁
偏向锁:一直被一个线程所访问,那么该线程会自动获取锁
轻量级锁(CAS):当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁:当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候(10次),还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让他申请的线程进入阻塞,性能降低。
表级锁:对整张表加锁,加锁快开销小,不会出现死锁,但并发度低,会增加锁冲突的概率 行级锁:是mysql粒度最小的锁,只针对操作行,可大大减少锁冲突概率,并发度高,但加锁慢,开销大,会出现死锁
Redis提供的各语言对红锁算法实现的框架
通过这可以了解到常用的基于Redis的分布式锁解决方案